feat: 新增插件系统、多种插件类型、开发指南及多语言文档。
This commit is contained in:
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# OpenWebUI specific
|
||||
# Add any specific ignores for OpenWebUI plugins if needed
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🚀 New Features
|
||||
- **Smart Mind Map**: Updated to v0.7.3. Improved description and metadata.
|
||||
- **Knowledge Card**: Updated to v0.2.1. Improved description and metadata.
|
||||
- **Documentation**: Added comprehensive `plugin_development_guide_cn.md` consolidating all previous guides.
|
||||
|
||||
### 📦 Project Structure
|
||||
- **Renamed**: Project renamed from `awesome-openwebui` to **OpenWebUI Extras**.
|
||||
- **Reorganized**:
|
||||
- Moved `run.py` to `scripts/`.
|
||||
- Moved large documentation files to `docs/`.
|
||||
- Removed `requirements.txt` to emphasize "resource collection" nature.
|
||||
- **Added**: `CONTRIBUTING.md` guide.
|
||||
|
||||
### 📝 Documentation
|
||||
- **README**: Updated English and Chinese READMEs with new project name and structure.
|
||||
- **Plan**: Updated `implementation_plan.md` to reflect the new direction.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2025-12-19
|
||||
- Initial release of the reorganized project structure.
|
||||
51
CONTRIBUTING.md
Normal file
51
CONTRIBUTING.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 贡献指南 (Contributing Guide)
|
||||
|
||||
感谢你对 **OpenWebUI Extras** 感兴趣!我们非常欢迎社区贡献更多的插件、提示词和创意。
|
||||
|
||||
## 🤝 如何贡献
|
||||
|
||||
### 1. 分享提示词 (Prompts)
|
||||
|
||||
如果你有一个好用的提示词:
|
||||
1. 在 `prompts/` 目录下找到合适的分类(如 `coding/`, `writing/`)。如果没有合适的,可以新建一个文件夹。
|
||||
2. 创建一个新的 `.md` 或 `.json` 文件。
|
||||
3. 提交 Pull Request (PR)。
|
||||
|
||||
### 2. 开发插件 (Plugins)
|
||||
|
||||
如果你开发了一个新的 OpenWebUI 插件 (Function/Tool):
|
||||
1. 确保你的插件代码包含完整的元数据(Frontmatter):
|
||||
```python
|
||||
"""
|
||||
title: 插件名称
|
||||
author: 你的名字
|
||||
version: 0.1.0
|
||||
description: 简短描述插件的功能
|
||||
"""
|
||||
```
|
||||
2. 将插件文件放入 `plugins/` 目录下的合适位置:
|
||||
- `plugins/actions/`: 用于添加按钮或修改消息的 Action 插件。
|
||||
- `plugins/filters/`: 用于拦截请求或响应的 Filter 插件。
|
||||
- `plugins/pipes/`: 用于自定义模型或 API 的 Pipe 插件。
|
||||
- `plugins/tools/`: 用于 LLM 调用的 Tool 插件。
|
||||
3. 建议在 `docs/` 下添加一个简单的使用说明。
|
||||
|
||||
### 3. 改进文档
|
||||
|
||||
如果你发现文档有错误或可以改进的地方,直接提交 PR 即可。
|
||||
|
||||
## 🛠️ 开发规范
|
||||
|
||||
- **代码风格**:Python 代码请遵循 PEP 8 规范。
|
||||
- **注释**:关键逻辑请添加注释,方便他人理解。
|
||||
- **测试**:提交前请在本地 OpenWebUI 环境中测试通过。
|
||||
|
||||
## 📝 提交 PR
|
||||
|
||||
1. Fork 本仓库。
|
||||
2. 创建一个新的分支 (`git checkout -b feature/AmazingFeature`)。
|
||||
3. 提交你的修改 (`git commit -m 'Add some AmazingFeature'`)。
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)。
|
||||
5. 开启一个 Pull Request。
|
||||
|
||||
再次感谢你的贡献!🚀
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Awesome OpenWebUI Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# OpenWebUI Extras
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
A collection of enhancements, plugins, and prompts for [OpenWebUI](https://github.com/open-webui/open-webui), developed and curated for personal use to extend functionality and improve experience.
|
||||
|
||||
[Contributing](./CONTRIBUTING.md) | [Changelog](./CHANGELOG.md)
|
||||
|
||||
## 📦 Project Contents
|
||||
|
||||
### 🧩 Plugins
|
||||
|
||||
Located in the `plugins/` directory, containing Python-based enhancements:
|
||||
|
||||
#### Actions
|
||||
- **Smart Mind Map** (`smart-mind-map`): Generates interactive mind maps from text.
|
||||
- **Knowledge Card** (`knowledge-card`): Creates beautiful flashcards for learning.
|
||||
- **Export to Excel** (`export_to_excel`): Exports chat history to Excel files.
|
||||
- **Summary** (`summary`): Text summarization tool.
|
||||
- **AI Agent Solver** (`ai-agent-solver`): Intelligent agent solver.
|
||||
|
||||
#### Filters
|
||||
- **Async Context Compression** (`async-context-compression`): Optimizes token usage via context compression.
|
||||
- **Context Enhancement** (`context_enhancement_filter`): Enhances chat context.
|
||||
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Companion filter for Gemini Manifold.
|
||||
- **Multi-Model Context Merger** (`multi_model_context_merger`): Merges context from multiple models.
|
||||
|
||||
#### Pipes
|
||||
- **Gemini Manifold** (`gemini_mainfold`): Pipeline for Gemini model integration.
|
||||
|
||||
#### Pipelines
|
||||
- **MoE Prompt Refiner** (`moe_prompt_refiner`): Refines prompts for Mixture of Experts (MoE) summary requests to generate high-quality comprehensive reports.
|
||||
|
||||
### 🎯 Prompts
|
||||
|
||||
Located in the `prompts/` directory, containing fine-tuned System Prompts:
|
||||
|
||||
- **Coding**: Programming assistance prompts.
|
||||
- **Marketing**: Marketing and copywriting prompts.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Located in the `docs/en/` directory:
|
||||
|
||||
- **[Plugin Development Guide](./docs/en/plugin_development_guide.md)** - The authoritative guide covering everything from getting started to advanced patterns and best practices. ⭐
|
||||
|
||||
For code examples, please check the `docs/examples/` directory.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
This project is a collection of resources and does not require a Python environment. Simply download the files you need and import them into your OpenWebUI instance.
|
||||
|
||||
### Using Prompts
|
||||
|
||||
1. Browse the `/prompts` directory and select a prompt file (`.md`).
|
||||
2. Copy the file content.
|
||||
3. In the OpenWebUI chat interface, click the "Prompt" button above the input box.
|
||||
4. Paste the content and save.
|
||||
|
||||
### Using Plugins
|
||||
|
||||
1. Browse the `/plugins` directory and download the plugin file (`.py`) you need.
|
||||
2. Go to OpenWebUI **Admin Panel** -> **Settings** -> **Plugins**.
|
||||
3. Click the upload button and select the `.py` file you just downloaded.
|
||||
4. Once uploaded, refresh the page to enable the plugin in your chat settings or toolbar.
|
||||
|
||||
### Contributing
|
||||
|
||||
If you have great prompts or plugins to share:
|
||||
1. Fork this repository.
|
||||
2. Add your files to the appropriate `prompts/` or `plugins/` directory.
|
||||
3. Submit a Pull Request.
|
||||
99
README_CN.md
Normal file
99
README_CN.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# OpenWebUI Extras
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Plugins)
|
||||
|
||||
位于 `plugins/` 目录,包含各类 Python 编写的功能增强插件:
|
||||
|
||||
#### Actions (交互增强)
|
||||
- **Smart Mind Map** (`smart-mind-map`): 智能分析文本并生成交互式思维导图。
|
||||
- **Knowledge Card** (`knowledge-card`): 快速生成精美的学习记忆卡片。
|
||||
- **Export to Excel** (`export_to_excel`): 将对话内容导出为 Excel 文件。
|
||||
- **Summary** (`summary`): 文本摘要生成工具。
|
||||
- **AI Agent Solver** (`ai-agent-solver`): 智能代理解决器。
|
||||
|
||||
#### Filters (消息处理)
|
||||
- **Async Context Compression** (`async-context-compression`): 异步上下文压缩,优化 Token 使用。
|
||||
- **Context Enhancement** (`context_enhancement_filter`): 上下文增强过滤器。
|
||||
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Gemini Manifold 配套增强。
|
||||
- **Multi-Model Context Merger** (`multi_model_context_merger`): 多模型上下文合并。
|
||||
|
||||
#### Pipes (模型管道)
|
||||
- **Gemini Manifold** (`gemini_mainfold`): 集成 Gemini 模型的管道。
|
||||
|
||||
#### Pipelines (工作流管道)
|
||||
- **MoE Prompt Refiner** (`moe_prompt_refiner`): 优化多模型 (MoE) 汇总请求的提示词,生成高质量的综合报告。
|
||||
|
||||
### 🎯 提示词 (Prompts)
|
||||
|
||||
位于 `prompts/` 目录,包含精心调优的 System Prompts:
|
||||
|
||||
- **Coding**: 编程辅助类提示词。
|
||||
- **Marketing**: 营销文案类提示词。(`/prompts/marketing`): 内容创作、品牌策划、市场分析相关的提示词
|
||||
|
||||
每个提示词都独立保存为 Markdown 文件,可直接在 OpenWebUI 中使用。
|
||||
|
||||
### 🔧 插件 (Plugins)
|
||||
|
||||
{{ ... }}
|
||||
|
||||
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)
|
||||
|
||||
## 📦 项目内容
|
||||
|
||||
### 🎯 提示词 (Prompts)
|
||||
|
||||
位于 `/prompts` 目录,包含针对不同领域的优质提示词模板:
|
||||
|
||||
- **编程类** (`/prompts/coding`): 代码生成、调试、优化相关的提示词
|
||||
- **营销类** (`/prompts/marketing`): 内容创作、品牌策划、市场分析相关的提示词
|
||||
|
||||
每个提示词都独立保存为 Markdown 文件,可直接在 OpenWebUI 中使用。
|
||||
|
||||
### 🔧 插件 (Plugins)
|
||||
|
||||
位于 `/plugins` 目录,提供三种类型的插件扩展:
|
||||
|
||||
- **过滤器 (Filters)** - 在用户输入发送给 LLM 前进行处理和优化
|
||||
- 异步上下文压缩:智能压缩长上下文,优化 token 使用效率
|
||||
|
||||
- **动作 (Actions)** - 自定义功能,从聊天中触发
|
||||
- 思维导图生成:快速生成和导出思维导图
|
||||
|
||||
- **管道 (Pipes)** - 对 LLM 响应进行处理和增强
|
||||
- 各类响应处理和格式化插件
|
||||
|
||||
## 📖 开发文档
|
||||
|
||||
位于 `docs/zh/` 目录:
|
||||
|
||||
- **[插件开发权威指南](./docs/zh/plugin_development_guide.md)** - 整合了入门教程、核心 SDK 详解及最佳实践的系统化指南。 ⭐
|
||||
- **[从问一个AI到运营一支AI团队](./docs/zh/从问一个AI到运营一支AI团队.md)** - 深度运营经验分享。
|
||||
|
||||
更多示例请查看 `docs/examples/` 目录。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
本项目是一个资源集合,无需安装 Python 环境。你只需要下载对应的文件并导入到你的 OpenWebUI 实例中即可。
|
||||
|
||||
### 使用提示词 (Prompts)
|
||||
|
||||
1. 在 `/prompts` 目录中浏览并选择你感兴趣的提示词文件 (`.md`)。
|
||||
2. 复制文件内容。
|
||||
3. 在 OpenWebUI 聊天界面中,点击输入框上方的 "Prompt" 按钮。
|
||||
4. 粘贴内容并保存。
|
||||
|
||||
### 使用插件 (Plugins)
|
||||
|
||||
1. 在 `/plugins` 目录中浏览并下载你需要的插件文件 (`.py`)。
|
||||
2. 打开 OpenWebUI 的 **管理员面板 (Admin Panel)** -> **设置 (Settings)** -> **插件 (Plugins)**。
|
||||
3. 点击上传按钮,选择刚才下载的 `.py` 文件。
|
||||
4. 上传成功后,刷新页面,你就可以在聊天设置或工具栏中启用该插件了。
|
||||
|
||||
### 贡献代码
|
||||
|
||||
如果你有优质的提示词或插件想要分享:
|
||||
1. Fork 本仓库。
|
||||
2. 将你的文件添加到对应的 `prompts/` 或 `plugins/` 目录。
|
||||
3. 提交 Pull Request。
|
||||
45
docs/en/gemini_manifold_plugin_philosophy.md
Normal file
45
docs/en/gemini_manifold_plugin_philosophy.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Gemini Manifold 插件开发哲学
|
||||
|
||||
## 概览
|
||||
|
||||
- 源码位于 `plugins/pipes/gemini_mainfold/gemini_manifold.py`,作为 Open WebUI 的 Pipe 插件,主要负责把前端的请求转化成 Google Gemini/Vertex AI 的调用,并将结果通过 `AsyncGenerator` 回流给前端。
|
||||
- 插件采用了 `Valves + UserValves` 的配置模式、异步事件与状态汇报、细粒度日志、文件缓存与上传中间件,以及统一响应处理器,充分体现了 Open WebUI 通用插件的开发模式。
|
||||
|
||||
## 核心模块
|
||||
|
||||
1. **`Pipe` 类(入口)**
|
||||
- `pipes` 方法注册可选模型,缓存模型列表并仅在配置变更时刷新。
|
||||
- `pipe` 方法为每个请求建立 Google GenAI 客户端、`EventEmitter` 与 `FilesAPIManager`,构建 `GeminiContentBuilder`,并统一把返回值交给 `_unified_response_processor`。
|
||||
|
||||
2. **`GeminiContentBuilder`(请求构建)**
|
||||
- 解析 Open WebUI 消息、引用历史、文件上传、YouTube/Markdown 媒体等内容,并通过 `UploadStatusManager` 与 `FilesAPIManager` 协作,确保上传进度可视。
|
||||
|
||||
3. **`FilesAPIManager`(文件缓存+上传)**
|
||||
- 采用 xxHash 内容地址、热/暖/冷路径、自定义锁、 TTL 缓存等手段防止重复上传,同时会在发生错误时用 `FilesAPIError` 抛出并告知前端。
|
||||
|
||||
4. **`EventEmitter` + `UploadStatusManager`(状态反馈)**
|
||||
- 抽象 Toast/Status/Completion 的交互,按需异步发送,赋予前端实时反馈能力,避免阻塞主流程。
|
||||
|
||||
5. **统一响应处理与后置处理**
|
||||
- `_unified_response_processor` 兼容流式/一次性响应,调用 `_process_part`、`_disable_special_tags` 保护前端,再在 `_do_post_processing` 发出 usage、grounding 等数据。
|
||||
|
||||
## 与 Open WebUI 插件哲学契合的实践
|
||||
|
||||
- **配置层叠与安全**:`Valves` 提供 admin 默认,`UserValves` 允许用户按需覆盖。`USER_MUST_PROVIDE_AUTH_CONFIG` + `AUTH_WHITELIST` 确保敏感场景必须使用各自凭证。
|
||||
- **异步状态与进度可视**:所有上传/调用都在 `EventEmitter` 中报告 toast/status,`UploadStatusManager` 用 queue 追踪并呈现进度,流式响应直接产出 `choices` chunk 与 `[DONE]`,前端无需额外猜测。
|
||||
- **功能可拓展性**:基于 `Functions.get_function_by_id` 检查 filter、依据 `features`/`toggle` 开启 Search、Code Execution、URL Context、Maps grounding,体现 Open WebUI 组件可组合的插件模型。
|
||||
- **文件与资源复用**:`FilesAPIManager` 通过 deterministic name、缓存、stateless GET,提高效率;生成图片也回写到 Open WebUI 的 `Files` 模型,为前端提供可持久化的 URL。
|
||||
- **透明日志与错误可控**:自定义 loguru handler(截断 `payload`)、统一的异常类、对 SDK 错误的 toast+error emission、对特殊 tag 的 ZWS 处理,确保插件运行时的状态始终可追踪并兼容前端。
|
||||
- **统一流程**:全链路从 request -> builder -> client -> response processor -> post-processing,严格对齐 Open WebUI pipe+filter 结构,便于扩展和维护。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
- 如果需要,可将上述内容整理到 Plugin README/开发指南中。也可以基于该文档再绘制流程图或生成寄语性文档,供团队使用。
|
||||
|
||||
## 进一步参考
|
||||
|
||||
详细的代码示例与使用场景请参见 `docs/gemini_manifold_plugin_examples.md`,包括:
|
||||
|
||||
- 配置层叠、异步事件、文件缓存等基础模式
|
||||
- 响应处理、标签防护、异常管理等中级技巧
|
||||
- 后处理流程、日志控制等高级实践
|
||||
50
docs/en/implementation_plan.md
Normal file
50
docs/en/implementation_plan.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 开源项目重组实施计划
|
||||
|
||||
## 1. 目标
|
||||
将 `openwebui-extras` 打造为一个 **OpenWebUI 增强功能集合库**,专注于分享个人开发和收集的优质插件、提示词,而非作为一个独立的 Python 应用程序发布。
|
||||
|
||||
## 2. 当前状态分析
|
||||
- **定位明确**:项目核心价值在于内容(Plugins, Prompts, Docs),而非运行环境。
|
||||
- **结构已优化**:
|
||||
- `plugins/`:核心插件资源。
|
||||
- `prompts/`:提示词资源。
|
||||
- `docs/`:详细的使用和开发文档。
|
||||
- `scripts/`:辅助工具脚本(如本地测试用的 `run.py`)。
|
||||
- **已移除不必要文件**:移除了 `requirements.txt`,避免用户误以为需要配置 Python 环境。
|
||||
|
||||
## 3. 重组方案
|
||||
|
||||
### 3.1 目录结构
|
||||
保持当前的清晰结构,强调“拿来即用”:
|
||||
|
||||
```
|
||||
openwebui-extras/
|
||||
├── docs/ # 文档与教程
|
||||
├── plugins/ # 插件库 (核心资源)
|
||||
│ ├── actions/
|
||||
│ ├── filters/
|
||||
│ ├── pipelines/
|
||||
│ └── pipes/
|
||||
├── prompts/ # 提示词库 (核心资源)
|
||||
├── scripts/ # 维护者工具 (非用户必须)
|
||||
├── LICENSE # MIT 许可证
|
||||
├── README.md # 项目入口与资源索引
|
||||
└── index.html # 项目展示页
|
||||
```
|
||||
|
||||
### 3.2 核心调整
|
||||
1. **移除依赖管理**:删除了 `requirements.txt`。用户不需要 `pip install` 任何东西,只需下载对应的 `.py` 或 `.md` 文件导入 OpenWebUI 即可。
|
||||
2. **文档侧重**:README 和文档将侧重于“如何下载”和“如何导入”,而不是“如何安装项目”。
|
||||
|
||||
### 3.3 后续建议
|
||||
1. **资源索引**:建议在 `README.md` 中维护一个高质量的插件/提示词索引表,方便用户快速查找。
|
||||
2. **贡献指南**:制定简单的 `CONTRIBUTING.md`,告诉其他人如何提交他们的插件或提示词(例如:只需提交文件到对应目录)。
|
||||
3. **版本控制**:虽然不需要 Python 环境,但建议在插件文件的头部注释中保留版本号和兼容性说明(如 `Compatible with OpenWebUI v0.3.x`)。
|
||||
|
||||
## 4. 发布流程
|
||||
1. **提交更改**:`git add . && git commit -m "Update project structure for resource sharing"`
|
||||
2. **推送到 GitHub**。
|
||||
3. **宣传**:在 OpenWebUI 社区分享此仓库链接。
|
||||
|
||||
---
|
||||
*生成时间:2025-12-19*
|
||||
234
docs/en/plugin_development_guide.md
Normal file
234
docs/en/plugin_development_guide.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# OpenWebUI Plugin Development Guide
|
||||
|
||||
> This guide consolidates official documentation, SDK details, and best practices to provide a systematic tutorial for developers, from beginner to expert.
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
1. [Quick Start](#1-quick-start)
|
||||
2. [Core Concepts & SDK Details](#2-core-concepts--sdk-details)
|
||||
3. [Deep Dive into Plugin Types](#3-deep-dive-into-plugin-types)
|
||||
* [Action](#31-action)
|
||||
* [Filter](#32-filter)
|
||||
* [Pipe](#33-pipe)
|
||||
4. [Advanced Development Patterns](#4-advanced-development-patterns)
|
||||
5. [Best Practices & Design Principles](#5-best-practices--design-principles)
|
||||
6. [Troubleshooting](#6-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick Start
|
||||
|
||||
### 1.1 What are OpenWebUI Plugins?
|
||||
|
||||
OpenWebUI Plugins (officially called "Functions") are the primary way to extend the platform's capabilities. Running in a backend Python environment, they allow you to:
|
||||
* 🔌 **Integrate New Models**: Connect to Claude, Gemini, or custom RAGs via Pipes.
|
||||
* 🎨 **Enhance Interaction**: Add buttons (e.g., "Export", "Generate Chart") next to messages via Actions.
|
||||
* 🔧 **Intervene in Processes**: Modify data before requests or after responses (e.g., inject context, filter sensitive words) via Filters.
|
||||
|
||||
### 1.2 Your First Plugin (Hello World)
|
||||
|
||||
Save the following code as `hello.py` and upload it to the **Functions** panel in OpenWebUI:
|
||||
|
||||
```python
|
||||
"""
|
||||
title: Hello World Action
|
||||
author: Demo
|
||||
version: 1.0.0
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
greeting: str = Field(default="Hello", description="Greeting message")
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__event_emitter__=None,
|
||||
__user__=None
|
||||
) -> Optional[dict]:
|
||||
user_name = __user__.get("name", "Friend") if __user__ else "Friend"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "success", "content": f"{self.valves.greeting}, {user_name}!"}
|
||||
})
|
||||
return body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Concepts & SDK Details
|
||||
|
||||
### 2.1 ⚠️ Important: Sync vs Async
|
||||
|
||||
OpenWebUI plugins run within an `asyncio` event loop.
|
||||
* **Principle**: All I/O operations (database, file, network) must be non-blocking.
|
||||
* **Pitfall**: Calling synchronous methods directly (e.g., `time.sleep`, `requests.get`) will freeze the entire server.
|
||||
* **Solution**: Wrap synchronous calls using `await asyncio.to_thread(sync_func, ...)`.
|
||||
|
||||
### 2.2 Core Parameters
|
||||
|
||||
All plugin methods (`inlet`, `outlet`, `pipe`, `action`) support injecting the following special parameters:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `body` | `dict` | **Core Data**. Contains request info like `messages`, `model`, `stream`. |
|
||||
| `__user__` | `dict` | **Current User**. Contains `id`, `name`, `role`, `valves` (user config), etc. |
|
||||
| `__metadata__` | `dict` | **Metadata**. Contains `chat_id`, `message_id`. The `variables` field holds preset variables like `{{USER_NAME}}`, `{{CURRENT_TIME}}`. |
|
||||
| `__request__` | `Request` | **FastAPI Request Object**. Access `app.state` for cross-plugin communication. |
|
||||
| `__event_emitter__` | `func` | **One-way Notification**. Used to send Toast notifications or status bar updates. |
|
||||
| `__event_call__` | `func` | **Two-way Interaction**. Used to execute JS code, show confirmation dialogs, or input boxes on the frontend. |
|
||||
|
||||
### 2.3 Configuration System (Valves)
|
||||
|
||||
* **`Valves`**: Global admin configuration.
|
||||
* **`UserValves`**: User-level configuration (higher priority, overrides global).
|
||||
|
||||
```python
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
API_KEY: str = Field(default="", description="Global API Key")
|
||||
|
||||
class UserValves(BaseModel):
|
||||
API_KEY: str = Field(default="", description="User Private API Key")
|
||||
|
||||
def inlet(self, body, __user__):
|
||||
# Prioritize user's Key
|
||||
user_valves = __user__.get("valves", self.UserValves())
|
||||
api_key = user_valves.API_KEY or self.valves.API_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Deep Dive into Plugin Types
|
||||
|
||||
### 3.1 Action
|
||||
|
||||
**Role**: Adds buttons below messages that trigger upon user click.
|
||||
|
||||
**Advanced Usage: Execute JavaScript on Frontend (File Download Example)**
|
||||
|
||||
```python
|
||||
import base64
|
||||
|
||||
async def action(self, body, __event_call__):
|
||||
# 1. Generate content on backend
|
||||
content = "Hello OpenWebUI".encode()
|
||||
b64 = base64.b64encode(content).decode()
|
||||
|
||||
# 2. Send JS to frontend for execution
|
||||
js = f"""
|
||||
const blob = new Blob([atob('{b64}')], {{type: 'text/plain'}});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'hello.txt';
|
||||
a.click();
|
||||
"""
|
||||
await __event_call__({"type": "execute", "data": {"code": js}})
|
||||
```
|
||||
|
||||
### 3.2 Filter
|
||||
|
||||
**Role**: Middleware that intercepts and modifies requests/responses.
|
||||
|
||||
* **`inlet`**: Before request. Used for injecting context, modifying model parameters.
|
||||
* **`outlet`**: After response. Used for formatting output, logging.
|
||||
* **`stream`**: During streaming. Used for real-time sensitive word filtering.
|
||||
|
||||
**Example: Injecting Environment Variables**
|
||||
|
||||
```python
|
||||
async def inlet(self, body, __metadata__):
|
||||
vars = __metadata__.get("variables", {})
|
||||
context = f"Current Time: {vars.get('{{CURRENT_DATETIME}}')}"
|
||||
|
||||
# Inject into System Prompt or first message
|
||||
if body.get("messages"):
|
||||
body["messages"][0]["content"] += f"\n\n{context}"
|
||||
return body
|
||||
```
|
||||
|
||||
### 3.3 Pipe
|
||||
|
||||
**Role**: Custom Model/Agent.
|
||||
|
||||
**Example: Simple OpenAI Wrapper**
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
class Pipe:
|
||||
def pipes(self):
|
||||
return [{"id": "my-gpt", "name": "My GPT Wrapper"}]
|
||||
|
||||
def pipe(self, body):
|
||||
# Modify body here, e.g., force add prompt
|
||||
headers = {"Authorization": f"Bearer {self.valves.API_KEY}"}
|
||||
r = requests.post("https://api.openai.com/v1/chat/completions", json=body, headers=headers, stream=True)
|
||||
return r.iter_lines()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Advanced Development Patterns
|
||||
|
||||
### 4.1 Pipe & Filter Collaboration
|
||||
Use `__request__.app.state` to share data between plugins.
|
||||
* **Pipe**: `__request__.app.state.search_results = [...]`
|
||||
* **Filter (Outlet)**: Read `search_results` and format them as citation links appended to the response.
|
||||
|
||||
### 4.2 Async Background Tasks
|
||||
Execute time-consuming operations (e.g., summarization, database storage) in the background without blocking the user response.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def outlet(self, body, __metadata__):
|
||||
asyncio.create_task(self.background_job(__metadata__["chat_id"]))
|
||||
return body
|
||||
|
||||
async def background_job(self, chat_id):
|
||||
# Execute time-consuming operation...
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Best Practices & Design Principles
|
||||
|
||||
### 5.1 Naming & Positioning
|
||||
* **Short & Punchy**: e.g., "FlashCard", "DeepRead". Avoid generic terms like "Text Analysis Assistant".
|
||||
* **Complementary**: Don't reinvent the wheel; clarify what specific problem your plugin solves.
|
||||
|
||||
### 5.2 User Experience (UX)
|
||||
* **Timely Feedback**: Send a `notification` ("Generating...") before time-consuming operations.
|
||||
* **Visual Appeal**: When Action outputs HTML, use modern CSS (rounded corners, shadows, gradients).
|
||||
* **Smart Guidance**: If text is too short, prompt the user: "Suggest entering more content for better results".
|
||||
|
||||
### 5.3 Error Handling
|
||||
Never let a plugin fail silently. Catch exceptions and inform the user via `__event_emitter__`.
|
||||
|
||||
```python
|
||||
try:
|
||||
# Business logic
|
||||
except Exception as e:
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"Processing failed: {str(e)}"}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
* **HTML not showing?** Ensure it's wrapped in a ` ```html ... ``` ` code block.
|
||||
* **Database error?** Check if you called synchronous DB methods directly in an `async` function; use `asyncio.to_thread`.
|
||||
* **Parameters not working?** Check if `Valves` are defined correctly and if they are being overridden by `UserValves`.
|
||||
117
docs/examples/action_plugin_export_to_excel_example_cn.md
Normal file
117
docs/examples/action_plugin_export_to_excel_example_cn.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# `Export to Excel` 插件深度解析:文件生成与下载实战
|
||||
|
||||
## 引言
|
||||
|
||||
`Export to Excel` 是一个非常实用的 `Action` 插件,它能智能地从 AI 的回答中提取 Markdown 表格,将其转换为格式精美的 Excel 文件,并直接在用户的浏览器中触发下载。
|
||||
|
||||
这个插件是一个绝佳的实战案例,它完整地展示了如何实现一个“数据处理 -> 文件生成 -> 前端交互”的闭环。通过解析它,开发者可以学习到如何在 Open WebUI 插件中利用强大的 Python 数据科学生态(如 `pandas`),以及如何实现将后端生成的文件无缝传递给用户。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
该插件的工作流程清晰而高效,可以概括为以下六个步骤:
|
||||
|
||||
1. **解析 (Parse)**: 使用正则表达式从最后一条聊天消息中精准地提取一个或多个 Markdown 表格。
|
||||
2. **分析 (Analyze)**: 智能地查找表格上下文中的 Markdown 标题(`#`, `##` 等),并以此为依据生成有意义的 Excel 工作簿及工作表(Sheet)的名称。
|
||||
3. **生成 (Generate)**: 将解析出的表格数据转换为 `pandas.DataFrame` 对象,这是进行后续处理的基础。
|
||||
4. **格式化与保存 (Format & Save)**: 利用 `pandas` 和 `XlsxWriter` 引擎,在服务器的临时目录中创建一个带有自定义样式(如颜色、对齐、自动列宽)的、符合专业规范的 `.xlsx` 文件。
|
||||
5. **传输与下载 (Transfer & Download)**: 将生成的 Excel 文件内容读取为字节流,进行 Base64 编码,然后通过 `__event_call__` 将编码后的字符串和一段 JavaScript 代码发送到前端。JS 代码在浏览器中解码数据、创建 Blob 对象并触发下载。
|
||||
6. **清理 (Cleanup)**: 下载触发后,立即删除服务器上的临时 Excel 文件,确保不占用服务器资源。
|
||||
|
||||
---
|
||||
|
||||
## 关键开发模式与技术剖析
|
||||
|
||||
### 1. 纯 Python 数据处理生态的威力
|
||||
|
||||
与一些需要深度集成 Open WebUI 后端模型的插件不同,`Export to Excel` 的核心功能完全由通用的 Python 库驱动,这展示了 Open WebUI 插件生态的开放性。
|
||||
|
||||
- **`re` (正则表达式)**: 用于从纯文本消息中稳健地解析出结构化的表格数据。
|
||||
- **`pandas`**: Python 数据分析的事实标准。插件用它来将原始的列表数据转换为强大的 DataFrame,为写入 Excel 提供了极大的便利。
|
||||
- **`xlsxwriter`**: 一个与 `pandas` 无缝集成的库,用于创建具有丰富格式的 Excel 文件,远比 `pandas` 默认的引擎功能更强大。
|
||||
|
||||
**启示**: 开发者可以将庞大而成熟的 Python 第三方库生态无缝地引入到 Open WebUI 插件中,以实现各种复杂的功能。
|
||||
|
||||
### 2. 智能文本上下文分析
|
||||
|
||||
一个优秀的插件不仅应完成任务,还应尽可能“智能”地理解用户意图。该插件的 `generate_names_from_content` 方法就是一个很好的例子。
|
||||
|
||||
- **目标**: 避免生成如 `output.xlsx` 或 `Sheet1` 这样无意义的文件/工作表名。
|
||||
- **实现**:
|
||||
1. 首先,遍历消息内容,找出所有的 Markdown 标题(`#` 到 `######`)及其所在的行号。
|
||||
2. 对于每一个提取出的表格,在所有位于其上方的标题中,选择**行号最大**(即距离最近)的一个作为该表格的名称。
|
||||
3. 如果只有一个表格,则直接使用其名称作为工作簿的名称。
|
||||
4. 如果有多个表格,则使用整篇消息中的**第一个标题**作为工作簿的名称。
|
||||
5. 如果找不到任何标题,则优雅地回退到默认命名方案(如 `用户_20231026.xlsx`)。
|
||||
|
||||
**启示**: 通过对上下文(而不只是目标数据本身)的简单分析,可以极大地提升插件的用户体验。
|
||||
|
||||
### 3. 高质量文件生成 (`pandas` + `xlsxwriter`)
|
||||
|
||||
简单地调用 `df.to_excel()` 只能生成一个“能用”的文件。而此插件通过 `apply_chinese_standard_formatting` 方法展示了如何生成一个“专业”的文件。
|
||||
|
||||
- **引擎选择**: `pd.ExcelWriter(file_path, engine="xlsxwriter")` 是关键,它允许我们访问底层的 `workbook` 和 `worksheet` 对象。
|
||||
- **核心格式化技术**:
|
||||
- **自定义单元格样式**: 通过 `workbook.add_format()` 创建多种样式(如表头、文本、数字、日期),并分别定义字体、颜色、边框、对齐方式等。
|
||||
- **智能内容对齐**: 遵循标准的制表规范,实现了“文本左对齐、数值右对齐、标题/日期/序号居中对齐”。
|
||||
- **中文字符感知列宽**: `calculate_text_width` 方法在计算内容宽度时,将中文字符(及标点)的宽度视为英文字符的两倍,确保了自动调整列宽 (`worksheet.set_column`) 对中文内容同样有效,避免了文字溢出。
|
||||
- **动态行高**: `calculate_text_height` 方法会根据单元格内容的换行符和折行情况计算所需行数,并以此为依据设置行高 (`worksheet.set_row`),确保了包含长文本的单元格也能完整显示。
|
||||
|
||||
**启示**: 魔鬼在细节中。对生成文件的精细格式化是区分“玩具”和“工具”的重要标准。
|
||||
|
||||
### 4. 后端文件生成与下载的标准模式
|
||||
|
||||
如何在 `Action` 插件中安全、高效地让用户下载后端生成的文件?`export_to_excel` 展示了目前**最佳的、也是标准的实现模式**。
|
||||
|
||||
**流程详解**:
|
||||
|
||||
1. **在服务器临时位置创建文件**:
|
||||
```python
|
||||
filename = f"{workbook_name}.xlsx"
|
||||
excel_file_path = os.path.join("app", "backend", "data", "temp", filename)
|
||||
# ... 使用 pandas 保存文件到 excel_file_path ...
|
||||
```
|
||||
2. **将文件读入内存并编码**:
|
||||
```python
|
||||
with open(excel_file_path, "rb") as file:
|
||||
file_content = file.read()
|
||||
base64_blob = base64.b64encode(file_content).decode("utf-8")
|
||||
```
|
||||
3. **通过 `__event_call__` 发送数据和下载指令**:
|
||||
- 将 Base64 字符串和文件名嵌入一段预设的 JavaScript 代码中。
|
||||
- 这段 JS 的作用是在浏览器端解码 Base64、创建文件 Blob、生成一个隐藏的下载链接 (`<a>` 标签),然后模拟用户点击该链接。
|
||||
|
||||
```python
|
||||
js_code = f"""
|
||||
const base64Data = "{base64_blob}";
|
||||
// ... JS 解码并创建下载链接的代码 ...
|
||||
a.download = "{filename}";
|
||||
a.click();
|
||||
"""
|
||||
await __event_call__({"type": "execute", "data": {"code": js_code}})
|
||||
```
|
||||
4. **立即清理临时文件**:
|
||||
```python
|
||||
if os.path.exists(excel_file_path):
|
||||
os.remove(excel_file_path)
|
||||
```
|
||||
|
||||
**模式优势**:
|
||||
- **安全**: 不会暴露服务器的任何文件路径或创建公共的下载 URL。
|
||||
- **无状态**: 服务器上不保留任何用户生成的文件,请求结束后立即清理,节约了存储空间。
|
||||
- **体验好**: 对用户来说,点击按钮后直接弹出浏览器下载框,体验非常流畅。
|
||||
|
||||
### 5. 优雅的错误处理
|
||||
|
||||
插件的 `action` 方法被一个完整的 `try...except` 块包裹。
|
||||
- 当 `extract_tables_from_message` 找不到表格时,它会主动抛出 `HTTPException`。
|
||||
- 在 `except` 块中,插件会通过 `__event_emitter__` 向前端发送一个内容为“没有找到可以导出的表格!”的错误通知 (`notification`),并更新状态栏 (`status`),清晰地告知用户发生了什么。
|
||||
|
||||
**启示**: 任何可能失败的操作都应被捕获,并向用户提供清晰、友好的错误反馈。
|
||||
|
||||
## 总结
|
||||
|
||||
`Export to Excel` 插件是一个将数据处理与前端交互完美结合的典范。通过学习它,我们可以掌握:
|
||||
- 如何利用 `pandas` 和 `xlsxwriter` 等库在后端生成专业品质的二进制文件。
|
||||
- 如何通过 `__event_call__` 这一强大的机制,实现从后端到前端的文件传输和下载触发。
|
||||
- 服务器临时文件的创建、使用和清理这一完整的、安全的生命周期管理模式。
|
||||
- 如何通过解析上下文来提升插件的“智能化”和用户体验。
|
||||
291
docs/examples/action_plugin_smart_mind_map_example_cn.md
Normal file
291
docs/examples/action_plugin_smart_mind_map_example_cn.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Open WebUI Action 插件开发范例:智绘心图
|
||||
|
||||
## 引言
|
||||
|
||||
“智绘心图” (`smart-mind-map`) 是一个功能强大的 Open WebUI Action 插件。它通过分析用户提供的文本,利用大语言模型(LLM)提取关键信息,并最终生成一个可交互的、可视化的思维导图。本文档将深入解析其源码 (`思维导图.py`),提炼其中蕴含的插件开发知识与最佳实践,为开发者提供一个高质量的参考范例。
|
||||
|
||||
## 核心开发知识点
|
||||
|
||||
- **插件元数据定义**: 如何通过文件头注释定义插件的标题、图标、版本和描述。
|
||||
- **可配置参数 (`Valves`)**: 如何为插件提供灵活的配置选项。
|
||||
- **异步 `action` 方法**: 插件主入口的实现方式及其核心参数的使用。
|
||||
- **实时前端交互 (`EventEmitter`)**: 如何向用户发送实时状态更新和通知。
|
||||
- **与 LLM 交互**: 如何构建动态 Prompt、调用内置 LLM 服务并处理返回结果。
|
||||
- **富文本 (HTML/JS) 输出**: 如何生成包含复杂前端逻辑的 HTML 内容,并将其嵌入聊天响应中。
|
||||
- **健壮性设计**: 如何实现输入验证、全面的错误处理和日志记录。
|
||||
- **访问 Open WebUI 核心模型**: 如何与 Open WebUI 的数据模型(如 `Users`)交互。
|
||||
|
||||
---
|
||||
|
||||
### 1. 插件元数据定义
|
||||
|
||||
Open WebUI 通过文件顶部的特定格式注释来识别和展示插件信息。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
"""
|
||||
title: 智绘心图
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSྡ1LjUiIHgyPSI2IiB5Mj0iNiIvPgogIDxjaXJjbGUgY3g9IjUiIGN5PSI1Iigcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSྡ5LjUgeDI9IjE1IiB5Mj0iNiIvPgogIDxjaXJjbGUgY3g9IjE5IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9ྡ1LjUgeTE9ྡ3MuNSB4Mj0iNiIgeTI9IjE4Ii8+CiAgPGNpcmNsZSBjeD0iNSIgY3k9IjE5IiByPSྡ1LjUiLz4KICA8bGluZSB4MT0ྡzIuNSB5MT0ྡzIuNSB4Mj0iNSIgeTI9IjE4Ii8+CiAgPGNpcmNsZSBjeD0ྡ5IiBjeT0ྡ5IiByPSྡ1LjUiLz4KPC9zdmc+Cg==
|
||||
version: 0.7.2
|
||||
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
|
||||
"""
|
||||
```
|
||||
**知识点**:
|
||||
- `title`: 插件在 UI 中显示的名称。
|
||||
- `icon_url`: 插件的图标,支持 base64 编码的 SVG,以实现无依赖的矢量图标。
|
||||
- `version`: 插件的版本号。
|
||||
- `description`: 插件的功能简介。
|
||||
|
||||
---
|
||||
|
||||
### 2. 可配置参数 (`Valves`)
|
||||
|
||||
通过在 `Action` 类内部定义一个 `Valves` Pydantic 模型,可以为插件创建可在 Web UI 中配置的参数。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=100, description="进行思维导图分析所需的最小文本长度(字符数)。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
```
|
||||
**知识点**:
|
||||
- `Valves` 类继承自 `pydantic.BaseModel`。
|
||||
- 每个字段都是一个配置项,`default` 是默认值,`description` 会在 UI 中作为提示信息显示。
|
||||
- 在 `__init__` 中实例化 `self.valves`,之后可以通过 `self.valves.PARAMETER_NAME` 来访问配置值。
|
||||
|
||||
---
|
||||
|
||||
### 3. 异步 `action` 方法
|
||||
|
||||
`action` 方法是插件的执行入口,它是一个异步函数,接收 Open WebUI 传入的上下文信息。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
# ... 插件逻辑 ...
|
||||
return body
|
||||
```
|
||||
**知识点**:
|
||||
- `body`: 包含当前聊天上下文的字典,最重要的是 `body.get("messages")`,它包含了完整的消息历史。
|
||||
- `__user__`: 包含当前用户信息的字典,如 `id`, `name`, `language` 等。插件中演示了如何兼容其为 `dict` 或 `list` 的情况。
|
||||
- `__event_emitter__`: 一个可调用的异步函数,用于向前端发送事件,是实现实时反馈的关键。
|
||||
- `__request__`: FastAPI 的 `Request` 对象,用于访问底层请求信息,例如在调用 `generate_chat_completion` 时需要传递。
|
||||
- **返回值**: `action` 方法需要返回修改后的 `body` 字典,其中包含了插件生成的响应。
|
||||
|
||||
---
|
||||
|
||||
### 4. 实时前端交互 (`EventEmitter`)
|
||||
|
||||
使用 `__event_emitter__` 可以极大地提升用户体验,让用户了解插件的执行进度。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
# 发送通知 (Toast)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info", # 'info', 'success', 'warning', 'error'
|
||||
"content": "智绘心图已启动,正在为您生成思维导图...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 发送状态更新 (Status Bar)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 深入分析文本结构...",
|
||||
"done": False, # False 表示进行中
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 任务完成
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 绘制完成!",
|
||||
"done": True, # True 表示已完成
|
||||
"hidden": False, # True 可以让成功状态自动隐藏
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
**知识点**:
|
||||
- **通知 (`notification`)**: 在屏幕角落弹出短暂的提示信息,适合用于触发、成功或失败的即时反馈。
|
||||
- **状态 (`status`)**: 在聊天输入框上方显示一个持久的状态条,适合展示多步骤任务的当前进度。`done: True` 会标记任务完成。
|
||||
|
||||
---
|
||||
|
||||
### 5. 与 LLM 交互
|
||||
|
||||
插件的核心功能通常依赖于 LLM。`智绘心图` 演示了如何构建一个结构化的 Prompt 并调用 LLM。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
# 1. 构建动态 Prompt
|
||||
SYSTEM_PROMPT_MINDMAP_ASSISTANT = "..." # 系统指令
|
||||
USER_PROMPT_GENERATE_MINDMAP = "..." # 用户指令模板
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_MINDMAP.format(
|
||||
user_name=user_name,
|
||||
# ... 注入其他上下文信息
|
||||
long_text_content=long_text_content,
|
||||
)
|
||||
|
||||
# 2. 准备 LLM 请求体
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_MINDMAP_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
# 3. 获取用户对象并调用 LLM
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
|
||||
# 4. 处理响应
|
||||
assistant_response_content = llm_response["choices"][0]["message"]["content"]
|
||||
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
||||
```
|
||||
**知识点**:
|
||||
- **Prompt 工程**: 将系统指令和用户指令分离。在用户指令中动态注入上下文信息(如用户名、时间、语言),可以使 LLM 的输出更具个性化和准确性。
|
||||
- **调用工具**: 使用 `open_webui.utils.chat.generate_chat_completion` 是与 Open WebUI 内置 LLM 服务交互的标准方式。
|
||||
- **用户上下文**: 调用 `generate_chat_completion` 需要传递 `user_obj`,这可能用于权限控制、计费或模型特定的用户标识。通过 `open_webui.models.users.Users.get_user_by_id` 获取该对象。
|
||||
- **响应解析**: LLM 的响应需要被解析。该插件使用正则表达式从返回的文本中提取核心的 Markdown 内容,并提供了回退机制。
|
||||
|
||||
---
|
||||
|
||||
### 6. 富文本 (HTML/JS) 输出
|
||||
|
||||
Action 插件的一大亮点是能够生成 HTML,从而在聊天界面中渲染丰富的交互式内容。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
# 1. 定义 HTML 模板
|
||||
HTML_TEMPLATE_MINDMAP = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- 引入 Markmap.js 等外部库 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.17"></script>
|
||||
<style>...</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 动态内容占位符 -->
|
||||
<div id="markmap-container-{unique_id}"></div>
|
||||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||||
<script>
|
||||
// 嵌入的 JavaScript 逻辑
|
||||
(function() {
|
||||
const uniqueId = "{unique_id}";
|
||||
// ... 渲染逻辑、事件处理 ...
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# 2. 注入动态内容
|
||||
final_html_content =
|
||||
HTML_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
||||
.replace("{markdown_syntax}", markdown_syntax)
|
||||
# ... 替换其他占位符
|
||||
|
||||
# 3. 嵌入到聊天响应中
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
```
|
||||
**知识点**:
|
||||
- **HTML 模板**: 将静态 HTML/CSS/JS 代码定义为模板字符串,使用占位符(如 `{unique_id}`)来注入动态数据。
|
||||
- **嵌入 JS**: 可以在 HTML 中直接嵌入 JavaScript 代码,用于处理前端交互逻辑,如渲染图表、绑定按钮事件等。`智绘心图` 的 JS 代码负责调用 Markmap.js 库来渲染思维导图,并实现了“复制 SVG”和“复制 Markdown”的按钮功能。
|
||||
- **唯一 ID**: 使用 `unique_id` 是一个好习惯,可以防止在同一页面上多次使用该插件时发生 DOM 元素 ID 冲突。
|
||||
- **响应格式**: 最终的 HTML 内容需要被包裹在 ````html\n...\n```` 代码块中,Open WebUI 的前端会自动识别并渲染它。
|
||||
- **内容追加**: 插件将生成的 HTML 追加到原始用户输入之后,而不是替换它,保留了上下文。
|
||||
|
||||
---
|
||||
|
||||
### 7. 健壮性设计
|
||||
|
||||
一个生产级的插件必须具备良好的健壮性。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
```python
|
||||
# 输入验证
|
||||
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
# ... 返回警告信息 ...
|
||||
return {"messages": [...]}
|
||||
|
||||
# 完整的异常捕获
|
||||
try:
|
||||
# ... 核心逻辑 ...
|
||||
except Exception as e:
|
||||
error_message = f"智绘心图处理失败: {str(e)}"
|
||||
logger.error(f"智绘心图错误: {error_message}", exc_info=True)
|
||||
|
||||
# 向前端发送错误通知
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(...)
|
||||
|
||||
# 在聊天中显示错误信息
|
||||
body["messages"][-1]["content"] = f"❌ **错误:** {user_facing_error}"
|
||||
|
||||
# 日志记录
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Action started")
|
||||
logger.error("Error occurred", exc_info=True)
|
||||
```
|
||||
**知识点**:
|
||||
- **输入验证**: 在执行核心逻辑前,对输入(如文本长度)进行检查,可以避免不必要的资源消耗和潜在错误。
|
||||
- **`try...except` 块**: 将主要逻辑包裹在 `try` 块中,并捕获 `Exception`,确保任何意外失败都能被优雅地处理。
|
||||
- **用户友好的错误反馈**: 在 `except` 块中,不仅要记录详细的错误日志(`logger.error`),还要通过 `EventEmitter` 和聊天消息向用户提供清晰、可操作的错误提示。
|
||||
- **日志**: 使用 `logging` 模块记录关键步骤和错误信息,是调试和监控插件运行状态的重要手段。`exc_info=True` 会记录完整的堆栈跟踪。
|
||||
|
||||
---
|
||||
|
||||
### 总结
|
||||
|
||||
`智绘心图` 插件是一个优秀的 Open WebUI Action 开发学习案例。它全面展示了如何利用 Action 插件的各项功能,构建一个交互性强、用户体验好、功能完整且健壮的 AI 应用。
|
||||
|
||||
**最佳实践总结**:
|
||||
- **明确元数据**: 为你的插件提供清晰的 `title`, `icon`, `description`。
|
||||
- **提供配置**: 使用 `Valves` 让插件更灵活。
|
||||
- **善用反馈**: 积极使用 `EventEmitter` 提供实时状态和通知。
|
||||
- **结构化 Prompt**: 精心设计的 Prompt 是高质量输出的保证。
|
||||
- **拥抱富文本**: 利用 HTML 和 JS 创造丰富的交互体验。
|
||||
- **防御性编程**: 始终考虑输入验证和错误处理。
|
||||
- **详细日志**: 记录日志是排查问题的关键。
|
||||
|
||||
通过学习和借鉴`智绘心图`的设计模式,开发者可以更高效地构建出属于自己的高质量 Open WebUI 插件。
|
||||
@@ -0,0 +1,235 @@
|
||||
# Open WebUI Filter 插件开发范例:异步上下文压缩
|
||||
|
||||
## 引言
|
||||
|
||||
“异步上下文压缩” (`async-context-compression`) 是一个功能先进的 Open WebUI `Filter` 插件。它旨在通过在后台异步地对长对话历史进行智能摘要,来显著减少发送给大语言模型(LLM)的 Token 数量,从而在节约成本的同时保持对话的连贯性。
|
||||
|
||||
本文档将深入剖析其源码,提炼其作为高级 `Filter` 插件所展示的设计模式与开发技巧,特别是关于**异步处理**、**数据库集成**和**复杂消息流控制**等方面。
|
||||
|
||||
## 核心开发知识点
|
||||
|
||||
- **Filter 插件结构 (`inlet` / `outlet`)**: 掌握过滤器在请求生命周期中的两个核心切入点。
|
||||
- **异步后台任务**: 如何使用 `asyncio.create_task` 执行耗时操作而不阻塞用户响应。
|
||||
- **数据库持久化**: 如何使用 SQLAlchemy 与数据库(PostgreSQL/SQLite)集成,实现数据的持久化存储。
|
||||
- **高级 `Valves` 配置**: 如何使用 Pydantic 的 `@model_validator` 实现复杂的跨字段配置验证。
|
||||
- **复杂消息体处理**: 如何安全地操作和修改包含多模态内容的消息结构。
|
||||
- **从插件内部调用 LLM**: 在插件中调用 LLM 服务以实现“插件调用插件”的元功能。
|
||||
- **环境变量依赖与初始化**: 如何处理对外部环境变量的依赖,并在插件初始化时进行安全配置。
|
||||
|
||||
---
|
||||
|
||||
### 1. Filter 插件结构 (`inlet` / `outlet`)
|
||||
|
||||
`Filter` 插件通过 `inlet` 和 `outlet` 两个方法,在请求发送给 LLM **之前**和 LLM 响应返回 **之后**对消息进行处理。
|
||||
|
||||
- `inlet(self, body: dict, ...)`: 在请求发送前执行。此插件用它来检查是否存在历史摘要,如果存在,则用摘要替换部分历史消息,从而“压缩”上下文。
|
||||
- `outlet(self, body: dict, ...)`: 在收到 LLM 响应后执行。此插件用它来判断对话是否达到了需要生成摘要的长度阈值,如果是,则触发一个后台任务来生成新的摘要,以供**下一次**对话使用。
|
||||
|
||||
这种“读旧,写新”的异步策略是该插件的核心设计。
|
||||
|
||||
**代码示例 (`async_context_compression.py`):**
|
||||
```python
|
||||
class Filter:
|
||||
def inlet(self, body: dict, ...) -> dict:
|
||||
"""
|
||||
在发送到 LLM 之前执行。
|
||||
应用已有的摘要来压缩本次请求的上下文。
|
||||
"""
|
||||
# 1. 从数据库加载已保存的摘要
|
||||
saved_summary = self._load_summary(chat_id, body)
|
||||
|
||||
# 2. 如果摘要存在且消息足够长
|
||||
if saved_summary and len(messages) > total_kept_count:
|
||||
# 3. 替换中间的消息为摘要
|
||||
body["messages"] = compressed_messages
|
||||
|
||||
return body
|
||||
|
||||
async def outlet(self, body: dict, ...) -> dict:
|
||||
"""
|
||||
在 LLM 响应完成后执行。
|
||||
检查是否需要为下一次请求生成新的摘要。
|
||||
"""
|
||||
# 1. 检查消息总数是否达到阈值
|
||||
if len(messages) >= self.valves.compression_threshold:
|
||||
# 2. 创建一个异步后台任务来生成摘要,不阻塞当前响应
|
||||
asyncio.create_task(
|
||||
self._generate_summary_async(...)
|
||||
)
|
||||
|
||||
return body
|
||||
```
|
||||
**知识点**:
|
||||
- `inlet` 和 `outlet` 分别作用于请求流的不同阶段,实现了功能的解耦。
|
||||
- `inlet` 负责**消费**摘要,`outlet` 负责**生产**摘要,两者通过数据库解耦。
|
||||
|
||||
---
|
||||
|
||||
### 2. 异步后台任务
|
||||
|
||||
对于耗时操作(如调用 LLM 生成摘要),为了不让用户等待,必须采用异步后台处理。这是高级插件必备的技巧。
|
||||
|
||||
**代码示例 (`async_context_compression.py`):**
|
||||
```python
|
||||
# 在 outlet 方法中
|
||||
async def outlet(self, ...):
|
||||
if len(messages) >= self.valves.compression_threshold:
|
||||
# 核心:创建一个后台任务,并立即返回,不等待其完成
|
||||
asyncio.create_task(
|
||||
self._generate_summary_async(messages, chat_id, body, __user__)
|
||||
)
|
||||
return body
|
||||
|
||||
# 后台任务的具体实现
|
||||
async def _generate_summary_async(self, ...):
|
||||
"""
|
||||
在后台异步生成摘要。
|
||||
"""
|
||||
try:
|
||||
# 1. 提取需要被摘要的消息
|
||||
messages_to_summarize = ...
|
||||
|
||||
# 2. 将消息格式化为纯文本
|
||||
conversation_text = self._format_messages_for_summary(messages_to_summarize)
|
||||
|
||||
# 3. 调用 LLM 生成摘要
|
||||
summary = await self._call_summary_llm(conversation_text, body, user_data)
|
||||
|
||||
# 4. 将新摘要存入数据库
|
||||
self._save_summary(chat_id, summary, body)
|
||||
except Exception as e:
|
||||
# 错误处理
|
||||
...
|
||||
```
|
||||
**知识点**:
|
||||
- `asyncio.create_task()`: 这是实现“即发即忘”(fire-and-forget)模式的关键。它将一个协程(`_generate_summary_async`)提交到事件循环中运行,而当前函数(`outlet`)可以继续执行并立即返回,从而确保了前端的快速响应。
|
||||
- **健壮性**: 后台任务必须有自己独立的 `try...except` 块,以防止其内部的失败影响到主程序的稳定性。
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据库持久化 (SQLAlchemy)
|
||||
|
||||
为了在不同对话回合乃至服务重启后都能保留摘要,插件集成了数据库。
|
||||
|
||||
**代码示例 (`async_context_compression.py`):**
|
||||
```python
|
||||
# 1. 依赖环境变量
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
|
||||
# 2. 定义数据模型
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
Base = declarative_base()
|
||||
|
||||
class ChatSummary(Base):
|
||||
__tablename__ = "chat_summary"
|
||||
id = Column(Integer, primary_key=True)
|
||||
chat_id = Column(String(255), unique=True, index=True)
|
||||
summary = Column(Text)
|
||||
# ... 其他字段
|
||||
|
||||
# 3. 初始化数据库连接
|
||||
def _init_database(self):
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
# ... 错误处理 ...
|
||||
return
|
||||
|
||||
# 根据 URL 前缀选择驱动 (PostgreSQL/SQLite)
|
||||
if database_url.startswith("sqlite"): ...
|
||||
elif database_url.startswith("postgres"): ...
|
||||
|
||||
self._db_engine = create_engine(database_url, ...)
|
||||
self._SessionLocal = sessionmaker(bind=self._db_engine)
|
||||
Base.metadata.create_all(bind=self._db_engine) # 自动建表
|
||||
|
||||
# 4. 封装 CRUD 操作
|
||||
def _save_summary(self, chat_id: str, summary: str, body: dict):
|
||||
session = self._SessionLocal()
|
||||
try:
|
||||
# ... 查询、更新或创建记录 ...
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
```
|
||||
**知识点**:
|
||||
- **配置驱动**: 插件依赖 `DATABASE_URL` 环境变量,并在 `_init_database` 中进行解析,实现了对不同数据库(PostgreSQL, SQLite)的兼容。
|
||||
- **ORM 模型**: 使用 SQLAlchemy 的声明式基类定义 `ChatSummary` 表结构,使数据库操作对象化,更易于维护。
|
||||
- **自动建表**: `Base.metadata.create_all()` 会在插件首次运行时自动检查并创建不存在的表,简化了部署。
|
||||
- **会话管理**: 使用 `sessionmaker` 创建会话,并通过 `try...finally` 确保会话在使用后被正确关闭,这是管理数据库连接的标准实践。
|
||||
|
||||
---
|
||||
|
||||
### 4. 高级 `Valves` 配置
|
||||
|
||||
除了简单的默认值,`Valves` 还可以通过 Pydantic 的验证器实现更复杂的逻辑。
|
||||
|
||||
**代码示例 (`async_context_compression.py`):**
|
||||
```python
|
||||
from pydantic import model_validator
|
||||
|
||||
class Valves(BaseModel):
|
||||
compression_threshold: int = Field(...)
|
||||
keep_first: int = Field(...)
|
||||
keep_last: int = Field(...)
|
||||
# ... 其他配置
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_thresholds(self) -> "Valves":
|
||||
kept_count = self.keep_first + self.keep_last
|
||||
if self.compression_threshold <= kept_count:
|
||||
raise ValueError(
|
||||
f"compression_threshold ({self.compression_threshold}) 必须大于 "
|
||||
f"keep_first 和 keep_last 的总和。"
|
||||
)
|
||||
return self
|
||||
```
|
||||
**知识点**:
|
||||
- `@model_validator(mode="after")`: 这个装饰器允许你在所有字段都已赋值**之后**,执行一个自定义的验证函数。
|
||||
- **跨字段验证**: 该插件用它来确保 `compression_threshold` 必须大于 `keep_first` 和 `keep_last` 之和,保证了插件逻辑的正确性,避免了无效配置。
|
||||
|
||||
---
|
||||
|
||||
### 5. 复杂消息体处理
|
||||
|
||||
Open WebUI 的消息体 `content` 可能是简单的字符串,也可能是用于多模态的列表。插件必须能稳健地处理这两种情况。
|
||||
|
||||
**代码示例 (`async_context_compression.py`):**
|
||||
```python
|
||||
def _inject_summary_to_first_message(self, message: dict, summary: str) -> dict:
|
||||
content = message.get("content", "")
|
||||
summary_block = f"【历史对话摘要】\n{summary}\n..."
|
||||
|
||||
if isinstance(content, list): # 多模态内容
|
||||
new_content = []
|
||||
summary_inserted = False
|
||||
for part in content:
|
||||
if part.get("type") == "text" and not summary_inserted:
|
||||
# 将摘要追加到第一个文本部分的前面
|
||||
new_content.append({"type": "text", "text": summary_block + part.get("text", "")})
|
||||
summary_inserted = True
|
||||
else:
|
||||
new_content.append(part)
|
||||
message["content"] = new_content
|
||||
elif isinstance(content, str): # 纯文本
|
||||
message["content"] = summary_block + content
|
||||
|
||||
return message
|
||||
```
|
||||
**知识点**:
|
||||
- **类型检查**: 通过 `isinstance(content, list)` 判断消息是否为多模态类型。
|
||||
- **安全注入**: 在处理多模态列表时,代码会遍历所有 `part`,找到第一个文本部分进行注入,同时保持其他部分(如图片)不变。这确保了插件的兼容性和稳定性。
|
||||
|
||||
---
|
||||
|
||||
### 总结
|
||||
|
||||
`异步上下文压缩` 插件是学习如何构建生产级 Open WebUI `Filter` 的绝佳案例。它不仅展示了 `Filter` 的基本用法,更深入地探讨了在 Web 服务中至关重要的**异步处理**和**持久化存储**。
|
||||
|
||||
**高级实践总结**:
|
||||
- **分离读写**: 利用 `inlet` 和 `outlet` 的生命周期,结合数据库,实现异步的“读写分离”模式。
|
||||
- **非阻塞设计**: 通过 `asyncio.create_task` 将耗时操作移出主请求/响应循环,保证用户体验的流畅性。
|
||||
- **外部依赖管理**: 优雅地处理对环境变量和数据库的依赖,并在初始化时提供清晰的日志和错误提示。
|
||||
- **健壮配置**: 利用模型验证器 (`@model_validator`) 防止用户设置出不符合逻辑的参数。
|
||||
- **兼容性处理**: 在操作消息体时,充分考虑多模态等复杂数据结构,确保插件的广泛适用性。
|
||||
|
||||
通过研究此插件,开发者可以掌握构建需要与外部服务(如数据库)交互、执行复杂后台任务的高级 `Filter` 的核心技能。
|
||||
@@ -0,0 +1,163 @@
|
||||
# `Gemini Manifold Companion` 深度解析:高级 `Filter` 与 `Pipe` 协同开发
|
||||
|
||||
## 引言
|
||||
|
||||
`Gemini Manifold Companion` 是一个 `Filter` 插件,但它的设计目标并非独立运作,而是作为 `Gemini Manifold` 这个 `Pipe` 插件的“伴侣”或“增强包”。它通过在请求到达 `Pipe` 之前和响应返回给用户之后进行一系列精巧的操作,解锁了许多 Open WebUI 原生界面不支持的、`Pipe` 专属的强大功能(如 Google Search, Code Execution 等)。
|
||||
|
||||
本文档将深度解析这个“伴侣插件”的设计模式,重点阐述其如何通过**拦截与翻译**、**跨阶段通信**和**异步 I/O** 等高级技巧,实现与 `Pipe` 插件的完美协同。
|
||||
|
||||
## 核心工作流:拦截与翻译 (Hijack and Translate)
|
||||
|
||||
`Companion` 插件的核心价值体现在其 `inlet` 方法中。它像一个智能的“请求路由器”,在不修改 Open WebUI 前端代码的情况下,将前端的通用功能开关“翻译”成 `Pipe` 插件能理解的专属指令。
|
||||
|
||||
**目标**: 拦截前端通用的功能请求(如“网络搜索”),阻止 Open WebUI 的默认行为,并将其转换为 `Pipe` 插件的专属功能标记。
|
||||
|
||||
#### 实现步骤 (`inlet` 方法):
|
||||
|
||||
1. **识别目标 `Pipe`**: 过滤器首先会检查当前请求是否发往它需要辅助的 `gemini_manifold`。如果不是,则直接返回,不做任何操作。这是伴侣插件模式的基础。
|
||||
|
||||
```python
|
||||
# _get_model_name 会判断当前模型是否由 gemini_manifold 提供
|
||||
canonical_model_name, is_manifold = self._get_model_name(body)
|
||||
if not is_manifold:
|
||||
return body # 不是目标,直接放行
|
||||
```
|
||||
|
||||
2. **拦截功能开关**: 插件检查前端请求的 `body["features"]` 中,`web_search` 是否为 `True`。
|
||||
|
||||
3. **执行“拦截与翻译”**:
|
||||
- **拦截 (Hijack)**: 如果 `web_search` 为 `True`,插件会立即将其改回 `False`。这一步至关重要,它阻止了 Open WebUI 触发其内置的、通用的 RAG 或网页搜索流程。
|
||||
```python
|
||||
features["web_search"] = False
|
||||
```
|
||||
- **翻译 (Translate)**: 紧接着,插件会在一个更深层的、用于插件间通信的 `metadata` 字典中,添加一个**自定义的**、`Pipe` 插件能识别的标志。
|
||||
```python
|
||||
# metadata["features"] 是一个专为插件间通信设计的字典
|
||||
metadata_features["google_search_tool"] = True
|
||||
```
|
||||
|
||||
4. **传递其他指令**: 除了功能开关,`Companion` 还会做一些其他的预处理,例如:
|
||||
- **绕过 RAG**: 如果用户开启了 `BYPASS_BACKEND_RAG`,它会清空 `body["files"]` 数组,并设置 `metadata_features["upload_documents"] = True`,告知 `Pipe` 插件“文件由你来处理”。
|
||||
- **强制流式**: `Pipe` 插件通常返回 `AsyncGenerator`,需要前端以流式模式处理。`Companion` 会强制设置 `body["stream"] = True`,同时将用户原始的流式/非流式选择保存在 `metadata` 中,供 `Pipe` 后续判断。
|
||||
|
||||
**设计模式的价值**: 这种模式实现了极高的解耦。前端只需使用标准的功能开关,而 `Pipe` 插件可以定义任意复杂的、私有的功能集。`Companion` 过滤器则充当了两者之间的智能适配器,使得在不改动核心代码的情况下,扩展后端功能成为可能。
|
||||
|
||||
---
|
||||
|
||||
## 高级技巧 1: `Pipe` -> `Filter` 的跨阶段通信
|
||||
|
||||
**问题**: `Pipe` 在处理过程中生成了重要数据(如包含搜索结果的 `grounding_metadata`),但 `Filter` 的 `outlet` 方法在 `Pipe` 执行**之后**才运行。如何将数据从 `Pipe` 安全地传递给 `Filter`?
|
||||
|
||||
**解决方案**: `request.app.state`,一个在单次 HTTP 请求生命周期内持续存在的共享状态对象。
|
||||
|
||||
#### 实现流程:
|
||||
|
||||
1. **`Pipe` 插件中 (数据写入)**:
|
||||
- 在 `gemini_manifold.py` 的 `_do_post_processing` 阶段(响应流结束后),`Pipe` 会从 Google API 的响应中提取 `grounding_metadata`。
|
||||
- 然后,它使用 `setattr` 将这些数据动态地附加到 `request.app.state` 对象上,使用一个包含 `chat_id` 和 `message_id` 的唯一键。
|
||||
|
||||
```python
|
||||
# 在 gemini_manifold.py 中 (示意代码)
|
||||
def _do_post_processing(self, ..., __request__: Request):
|
||||
app_state = __request__.app.state
|
||||
grounding_key = f"grounding_{chat_id}_{message_id}"
|
||||
|
||||
# 将数据存入请求状态
|
||||
setattr(app_state, grounding_key, grounding_metadata)
|
||||
```
|
||||
|
||||
2. **`Companion Filter` 中 (数据读取)**:
|
||||
- 在 `outlet` 方法中,`Filter` 可以访问同一个 `__request__` 对象。
|
||||
- 它使用 `getattr` 和相同的唯一键,从 `request.app.state` 中安全地取出 `Pipe` 之前存入的数据。
|
||||
|
||||
```python
|
||||
# 在 gemini_manifold_companion.py 的 outlet 方法中
|
||||
async def outlet(self, ..., __request__: Request, ...):
|
||||
app_state = __request__.app.state
|
||||
grounding_key = f"grounding_{chat_id}_{message_id}"
|
||||
|
||||
# 从请求状态中读取数据
|
||||
stored_metadata = getattr(app_state, grounding_key, None)
|
||||
|
||||
if stored_metadata:
|
||||
# 成功获取 Pipe 传来的数据,进行后续处理
|
||||
# (如注入引用标记、解析 URL 等)
|
||||
...
|
||||
|
||||
# 清理状态,避免内存泄漏
|
||||
delattr(app_state, grounding_key)
|
||||
```
|
||||
|
||||
**设计模式的价值**: `request.app.state` 充当了在同一次请求处理链中、不同插件(特别是 `Pipe` 和 `Filter`)之间传递复杂数据的“秘密信道”,是实现高级协同功能的关键。
|
||||
|
||||
---
|
||||
|
||||
## 高级技巧 2: 在 `outlet` 中执行异步 I/O
|
||||
|
||||
**问题**: `grounding_metadata` 中的搜索结果 URL 是 Google 的重定向链接,需要通过网络请求解析成最终的真实网址才能展示给用户。如果在 `outlet` 中同步执行这些请求,会阻塞整个响应流程。
|
||||
|
||||
**解决方案**: 利用 `outlet` 是 `async` 函数的特性,执行并发的异步网络请求。
|
||||
|
||||
#### 实现流程 (`_resolve_and_emit_sources` 方法):
|
||||
|
||||
1. **收集任务**: 从 `grounding_metadata` 中提取所有需要解析的 URL。
|
||||
2. **创建会话**: 使用 `aiohttp.ClientSession` 创建一个异步 HTTP 客户端会话。
|
||||
3. **并发执行**:
|
||||
- 为每个 URL 创建一个 `_resolve_url` 协程任务。
|
||||
- 使用 `asyncio.gather` 并发地执行所有 URL 解析任务。
|
||||
4. **处理结果**: 等待所有解析完成后,将最终的真实 URL 和其他元数据组合成 `sources` 列表。
|
||||
5. **发送事件**: 通过 `__event_emitter__` 将包含最终 `sources` 的 `chat:completion` 事件发送给前端。
|
||||
|
||||
**代码示例 (逻辑简化):**
|
||||
```python
|
||||
async def _resolve_and_emit_sources(self, ...):
|
||||
# ... 提取所有待解析的 URL ...
|
||||
urls_to_resolve = [...]
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 为每个 URL 创建一个异步解析任务
|
||||
tasks = [self._resolve_url(session, url) for url in urls_to_resolve]
|
||||
# 并发执行所有任务
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# ... 处理解析结果 ...
|
||||
resolved_sources = [...]
|
||||
|
||||
# 通过事件发射器将最终结果发送给前端
|
||||
await event_emitter({"type": "chat:completion", "data": {"sources": resolved_sources}})
|
||||
```
|
||||
**设计模式的价值**: 即使在请求处理的最后阶段 (`outlet`),也能够执行高效、非阻塞的 I/O 操作,极大地丰富了插件的能力,而不会牺牲用户体验。
|
||||
|
||||
---
|
||||
|
||||
## 高级技巧 3: 动态日志级别
|
||||
|
||||
**问题**: 如何在不重启服务的情况下,动态调整一个插件的日志详细程度,以便于在线上环境中进行调试?
|
||||
|
||||
**解决方案**: 在 `inlet` 中检查配置变化,并动态地添加/移除 `loguru` 的日志处理器 (Handler)。
|
||||
|
||||
#### 实现流程:
|
||||
|
||||
1. **`__init__`**: 插件初始化时,根据 `Valves` 中的 `LOG_LEVEL` 配置,添加一个带特定过滤器(只输出本插件日志)和格式化器的 `loguru` handler。
|
||||
2. **`inlet`**: 在每次请求进入时,都比较当前阀门中的 `LOG_LEVEL` 与插件实例中保存的 `self.log_level` 是否一致。
|
||||
3. **动态更新**:
|
||||
- 如果不一致,说明管理员修改了配置。
|
||||
- 插件会调用 `log.remove()` 移除旧的 handler。
|
||||
- 然后调用 `log.add()`,使用新的日志级别添加一个新的 handler。
|
||||
- 最后更新 `self.log_level`。
|
||||
|
||||
**设计模式的价值**: 这使得插件的日志管理变得极其灵活。管理员只需在 Web UI 中修改插件的 `LOG_LEVEL` 配置,即可立即(在下一次请求时)看到更详细或更简洁的日志输出,极大地提升了生产环境中的问题排查效率。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
`Gemini Manifold Companion` 是一个教科书级的“伴侣插件”范例,它揭示了 `Filter` 插件的巨大潜力。通过学习它,我们可以掌握:
|
||||
|
||||
- **协同设计模式**: 如何让 `Filter` 与 `Pipe` 协同工作,以实现标准 UI 之外的复杂功能。
|
||||
- **指令翻译**: 使用 `metadata` 作为 `Filter` 向 `Pipe` 传递“秘密指令”的通信渠道。
|
||||
- **跨阶段状态共享**: 使用 `request.app.state` 作为 `Pipe` 向 `Filter` 回传数据的“临时内存”。
|
||||
- **全异步流程**: 即使在请求的末端 (`outlet`),也能利用 `asyncio` 和 `aiohttp` 执行高效的异步 I/O 操作。
|
||||
- **动态运维能力**: 实现如动态日志级别这样的功能,让插件更易于在生产环境中管理和调试。
|
||||
|
||||
这些高级技巧共同构成了一个强大、解耦且可扩展的插件生态系统,是所有 Open WebUI 插件开发者进阶的必经之路。
|
||||
134
docs/examples/filter_plugin_inject_env_example_cn.md
Normal file
134
docs/examples/filter_plugin_inject_env_example_cn.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# `Inject Env` 插件深度解析:动态修改请求与上下文注入
|
||||
|
||||
## 引言
|
||||
|
||||
`Inject Env` 是一个 `Filter` 插件的绝佳范例,它清晰地展示了过滤器的核心价值:在请求到达 LLM **之前** (`inlet` 阶段) 对其进行拦截和动态修改。
|
||||
|
||||
该插件的核心功能包括:
|
||||
1. 将用户的环境变量(如姓名、当前时间)自动注入到对话的起始位置。
|
||||
2. 根据当前使用的模型和用户信息,智能地开启、关闭或重定向“网络搜索”功能。
|
||||
3. 为特定模型补充必要的 API 参数(如 `chat_id`)。
|
||||
|
||||
通过解析这个插件,开发者可以掌握如何构建一个能够感知上下文(用户、模型、环境变量)并据此动态调整请求内容的智能过滤器。
|
||||
|
||||
---
|
||||
|
||||
## 核心工作流 (`inlet` 方法)
|
||||
|
||||
该插件的所有逻辑都集中在 `inlet` 方法中,其工作流程可以分解为:
|
||||
|
||||
1. **注入上下文**: 从 `__metadata__` 参数中获取用户环境变量,并将其作为一个格式化的 Markdown 块,智能地插入到第一条用户消息的开头。
|
||||
2. **控制功能**: 分析当前请求的模型名称 (`body['model']`) 和用户信息 (`__user__`),应用一系列规则来决定如何处理“网络搜索”功能。
|
||||
3. **补充参数**: 根据模型信息 (`__model__`),为特定的模型(如 `cfchatqwen`)在请求体 `body` 中补充其所需的 `chat_id` 等参数。
|
||||
|
||||
---
|
||||
|
||||
## 关键开发模式与技术剖析
|
||||
|
||||
### 1. 利用 `__metadata__` 和 `__model__` 获取丰富上下文
|
||||
|
||||
`Filter` 插件的 `inlet` 方法可以接收 `__metadata__` 和 `__model__` 这两个非常有用的参数,它们是插件感知上下文、实现智能化逻辑的关键。
|
||||
|
||||
- **`__metadata__["variables"]` (环境变量)**:
|
||||
- **功能**: 这是一个由 Open WebUI 自动填充的、包含当前请求上下文信息的字典。
|
||||
- **内容**: 它预置了一系列模板变量,如:
|
||||
- `{{USER_NAME}}`: 当前用户名
|
||||
- `{{CURRENT_DATETIME}}`: 当前日期时间
|
||||
- `{{CURRENT_WEEKDAY}}`: 当前星期
|
||||
- `{{CURRENT_TIMEZONE}}`: 当前时区
|
||||
- `{{USER_LANGUAGE}}`: 用户的语言设置
|
||||
- **价值**: 这是在插件中获取用户和环境信息的**标准方式**,无需手动计算。`Inject Env` 插件正是利用这个字典来构建注入到消息中的 Markdown 文本。
|
||||
|
||||
- **`__model__` (模型信息)**:
|
||||
- **功能**: 这是一个包含了当前交易所用模型详细信息的字典。
|
||||
- **内容**: 开发者可以从中获取模型的 `id`、`info.base_model_id`(对于自定义模型,指向其基础模型)等。
|
||||
- **价值**: 允许插件根据不同的模型或模型家族(例如,检查 `base_model_id` 是否以 `qwen` 开头)来执行不同的逻辑分支。
|
||||
|
||||
**代码示例:**
|
||||
```python
|
||||
def inlet(
|
||||
self,
|
||||
body: dict,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__model__: Optional[dict] = None,
|
||||
) -> dict:
|
||||
# 从 __metadata__ 获取环境变量
|
||||
variables = __metadata__.get("variables", {})
|
||||
if variables:
|
||||
variable_markdown = f"- **用户姓名**:{variables.get('{{USER_NAME}}', '')}\n"
|
||||
# ... 注入到消息中 ...
|
||||
|
||||
# 从 __model__ 获取模型基础 ID
|
||||
if "openai" in __model__:
|
||||
base_model_id = __model__["openai"]["id"]
|
||||
else:
|
||||
base_model_id = __model__["info"]["base_model_id"]
|
||||
|
||||
if base_model_id.startswith("cfchatqwen"):
|
||||
# ... 执行针对 qwen 模型的特定逻辑 ...
|
||||
```
|
||||
|
||||
### 2. 健壮的消息内容注入
|
||||
|
||||
向用户的消息中动态添加内容时,必须考虑多种情况以确保插件的健壮性。`insert_user_env_info` 函数为此提供了完美的示范。
|
||||
|
||||
- **幂等性注入 (Idempotent Injection)**:
|
||||
- **问题**: 如果每次都简单地在消息前添加内容,当用户连续对话时,环境变量块会被重复注入,造成内容冗余。
|
||||
- **解决方案**: 在注入前,先用正则表达式 `re.search()` 检查消息中是否**已存在**环境变量块。
|
||||
- 如果**存在**,则使用 `re.sub()` 将其**替换**为最新的内容。
|
||||
- 如果**不存在**,才在消息开头**添加**新内容。
|
||||
- **价值**: 保证了无论 `inlet` 被调用多少次,环境变量信息在消息中只会出现一次,并且始终保持最新。
|
||||
|
||||
- **兼容多模态消息**:
|
||||
- **问题**: 用户的消息 `content` 可能是纯文本字符串,也可能是一个包含文本和图片的列表(`[{'type':'text', ...}, {'type':'image_url', ...}]`)。简单地进行字符串拼接会破坏多模态结构。
|
||||
- **解决方案**:
|
||||
1. 使用 `isinstance(content, list)` 检查内容是否为列表。
|
||||
2. 如果是列表,则遍历它,找到 `type` 为 `text` 的那部分。
|
||||
3. 对文本部分执行上述的“幂等性注入”逻辑。
|
||||
4. 如果列表中**没有**文本部分(例如,用户只发了一张图片),则**主动插入**一个新的文本部分 `{'type': 'text', 'text': ...}` 到列表的开头。
|
||||
|
||||
**启示**: 对消息体的任何修改都必须考虑其数据结构(`str` 或 `list`),并进行相应的处理,以确保插件的广泛兼容性。
|
||||
|
||||
### 3. 基于模型的动态路由与功能切换
|
||||
|
||||
`change_web_search` 函数是“拦截与翻译”模式的又一个精彩应用,并且引入了更高级的“模型重定向”技巧。
|
||||
|
||||
- **模式一:参数翻译 (适用于通义千问)**
|
||||
- **场景**: `qwen-max` 模型可能不认识 Open WebUI 的标准 `web_search` 开关,而是需要一个名为 `enable_search` 的参数。
|
||||
- **实现**:
|
||||
1. 拦截:`features["web_search"] = False`
|
||||
2. 翻译:`body.setdefault("enable_search", True)`
|
||||
- **效果**: 对用户透明地将会话切换到了模型的原生搜索模式。
|
||||
|
||||
- **模式二:模型重定向 (适用于 Deepseek/Gemini 等)**
|
||||
- **场景**: 某个模型系列(如 `deepseek`)本身不支持搜索,但其提供商部署了一个带搜索功能的版本,其模型名称可能是 `deepseek-chat-search`。
|
||||
- **实现**:
|
||||
1. 检查当前模型是否为 `cfdeepseek-deepseek` 且**不**以 `-search` 结尾。
|
||||
2. 如果是,则**直接修改请求体中的模型名称**: `body["model"] = body["model"] + "-search"`。
|
||||
3. 最后,禁用标准的 `web_search` 开关:`features["web_search"] = False`。
|
||||
- **效果**: 这种方式巧妙地将用户的请求“重定向”到了一个功能更强的模型版本,而用户在前端选择的仍然是普通模型。这为插件开发者提供了极大的灵活性,可以创建功能增强的“虚拟模型”。
|
||||
|
||||
### 4. 用户特定逻辑
|
||||
|
||||
插件还可以根据用户信息执行特定逻辑,这对于 A/B 测试、灰度发布或为特定用户提供定制功能非常有用。
|
||||
|
||||
**代码示例:**
|
||||
```python
|
||||
# 从 __user__ 参数中获取用户邮箱
|
||||
user_email = __user__.get("email")
|
||||
|
||||
# 为特定用户禁用网络搜索
|
||||
if user_email == "yi204o@qq.com":
|
||||
features["web_search"] = False
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
`Inject Env` 插件虽然代码量不大,但它像一把精准的手术刀,展示了 `Filter` 插件在请求预处理阶段的强大能力。通过学习它,我们可以掌握:
|
||||
|
||||
- **利用上下文**: 如何充分利用 `__metadata__` 和 `__model__` 参数,让插件变得“智能”和“情境感知”。
|
||||
- **稳健地修改内容**: 如何在不破坏多模态结构和保证幂等性的前提下,向用户消息中注入信息。
|
||||
- **高级功能控制**: 如何通过“参数翻译”和“模型重定向”等高级技巧,实现对模型功能(如网络搜索)的精细化控制。
|
||||
- **构建模板**: 这个插件是任何需要在请求发送前注入动态信息(如 Prompt Engineering、上下文增强、参数调整)的过滤器的绝佳起点。
|
||||
|
||||
```
|
||||
1838
docs/examples/gemini_manifold_plugin_examples.md
Normal file
1838
docs/examples/gemini_manifold_plugin_examples.md
Normal file
File diff suppressed because it is too large
Load Diff
185
docs/examples/pipe_plugin_gemini_manifold_example_cn.md
Normal file
185
docs/examples/pipe_plugin_gemini_manifold_example_cn.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# `Gemini Manifold` 插件深度解析:高级 `Pipe` 插件开发指南
|
||||
|
||||
## 引言
|
||||
|
||||
`Gemini Manifold` (`gemini_manifold.py`) 不仅仅是一个连接到 Google AI 服务的 `Pipe` 插件,它更是一个集成了高级架构设计、复杂功能和最佳实践的“瑞士军刀”。它作为 Open WebUI 与 Google Gemini 及 Vertex AI 之间的桥梁,全面展示了如何构建一个生产级的、功能丰富的、高性能且用户体验良好的 `Pipe` 插件。
|
||||
|
||||
本文档是对该插件的**深度解析**,旨在帮助开发者通过剖析一个顶级的范例,掌握 Open WebUI 高级插件的开发思想与核心技术。
|
||||
|
||||
## Part 1: 复杂配置管理艺术 (`Valves` 系统)
|
||||
|
||||
在复杂的应用场景中,配置管理需要同时兼顾安全性、灵活性和多用户隔离。`Gemini Manifold` 通过一个精巧的双层 `Valves` 系统完美地解决了这个问题。
|
||||
|
||||
**目标**: 解决多用户、多环境下的配置灵活性与安全性问题。
|
||||
|
||||
#### 1.1 双层结构:`Valves` 与 `UserValves`
|
||||
|
||||
- **`Pipe.Valves` (管理员层)**: 定义了插件的全局默认配置,由管理员在 Open WebUI 的设置界面中配置。这些是插件运行的基础。
|
||||
|
||||
```python
|
||||
class Pipe:
|
||||
class Valves(BaseModel):
|
||||
GEMINI_API_KEY: str | None = Field(default=None)
|
||||
USE_VERTEX_AI: bool = Field(default=False)
|
||||
USER_MUST_PROVIDE_AUTH_CONFIG: bool = Field(default=False)
|
||||
AUTH_WHITELIST: str | None = Field(default=None)
|
||||
# ... 40+ 其他全局配置
|
||||
```
|
||||
|
||||
- **`Pipe.UserValves` (用户层)**: 允许每个用户在每次请求时,通过请求体(`body`)传入自己的配置,用于临时覆盖管理员的默认设置。
|
||||
|
||||
```python
|
||||
class Pipe:
|
||||
class UserValves(BaseModel):
|
||||
GEMINI_API_KEY: str | None = Field(default=None)
|
||||
USE_VERTEX_AI: bool | None | Literal[""] = Field(default=None)
|
||||
# ... 其他用户可覆盖的配置
|
||||
```
|
||||
|
||||
#### 1.2 核心合并逻辑 `_get_merged_valves`
|
||||
|
||||
该函数在每次请求时被调用,负责将 `UserValves` 合并到 `Valves` 中,生成最终生效的配置。
|
||||
|
||||
#### 1.3 关键模式:强制认证与白名单
|
||||
|
||||
这是该配置系统中最精妙的部分,专为需要进行成本分摊和安全管控的团队环境设计。
|
||||
|
||||
- **场景**: 公司希望员工使用自己的 API Key,而不是共用一个高额度的 Key。
|
||||
- **实现**:
|
||||
1. 管理员在 `Valves` 中设置 `USER_MUST_PROVIDE_AUTH_CONFIG: True`。
|
||||
2. 同时,可以将少数特权用户(如测试人员)的邮箱加入 `AUTH_WHITELIST`。
|
||||
3. 在合并配置时,插件会检查当前用户是否在白名单内。
|
||||
- **非白名单用户**: **强制**使用其在 `UserValves` 中提供的 `GEMINI_API_KEY`,并**禁用**管理员配置的 `USE_VERTEX_AI`。如果用户没提供 Key,请求会失败。
|
||||
- **白名单用户**: 不受此限制,可以正常使用管理员配置的默认值。
|
||||
|
||||
这种设计通过代码强制执行了组织的策略,比单纯的文档约定要可靠得多。
|
||||
|
||||
## Part 2: 高性能文件上传与缓存 (`FilesAPIManager`)
|
||||
|
||||
`FilesAPIManager` 是该插件的性能核心,它通过一套复杂但高效的机制,解决了文件上传中的重复、并发和性能三大难题。
|
||||
|
||||
**目标**: 避免重复上传,减少API调用,并在高并发下保持稳定。
|
||||
|
||||
#### 2.1 核心概念:内容寻址 (Content-Addressable Storage)
|
||||
|
||||
- **原理**: 文件的唯一标识符**不是文件名**,而是其**文件内容的哈希值**。插件使用 `xxhash`(一种速度极快的非加密哈希算法)来计算文件哈希。
|
||||
- **优势**: 无论一个文件被上传多少次,只要内容不变,其哈希值就永远相同。这意味着插件只需为每个独一无二的文件内容执行一次上传操作。
|
||||
|
||||
#### 2.2 实现:三级缓存路径 (Hot/Warm/Cold Path)
|
||||
|
||||
`FilesAPIManager` 的 `get_or_upload_file` 方法实现了精妙的三级缓存策略:
|
||||
|
||||
1. **Hot Path (内存缓存)**:
|
||||
- **实现**: 使用 `aiocache` 将“文件哈希 -> `types.File` 对象”的映射关系缓存在内存中。`types.File` 对象包含了 Google API 返回的文件 URI 和过期时间。
|
||||
- **流程**: 收到文件后,先查内存缓存。如果命中,直接返回 `types.File` 对象,无任何网络 I/O,速度最快。
|
||||
|
||||
2. **Warm Path (无状态恢复)**:
|
||||
- **场景**: 内存缓存未命中(例如服务重启,内存被清空)。
|
||||
- **实现**: 插件根据文件哈希构造一个**确定性的文件名**(`deterministic_name = f"files/owui-v1-{content_hash}"`),然后直接调用 `client.aio.files.get()` 尝试从 Google API 获取该文件。
|
||||
- **优势**: 如果文件之前被上传过,这次 `get` 调用就会成功,并返回文件的状态信息。这样**仅用一次轻量的 `GET` 请求就恢复了文件状态,避免了昂贵的重新上传**。
|
||||
|
||||
3. **Cold Path (文件上传)**:
|
||||
- **场景**: Hot 和 Warm 路径全部失败,说明这确实是一个新文件(或者在 Google 服务器上已过期)。
|
||||
- **实现**: 执行完整的文件上传流程,并将成功后的 `types.File` 对象存入内存缓存(Hot Path),以备后续使用。
|
||||
|
||||
#### 2.3 关键模式:并发上传安全
|
||||
|
||||
- **问题**: 如果 10 个用户同时上传同一个大文件,会发生什么?
|
||||
- **解决方案**: 使用 `asyncio.Lock` 结合 "双重检查锁定" (Double-Checked Locking) 模式。
|
||||
1. 为每一个**文件哈希**维护一个独立的 `asyncio.Lock`。
|
||||
2. 当一个任务进入 `get_or_upload_file` 时,它会先尝试获取该文件哈希对应的锁。
|
||||
3. **第一个任务**会成功获取锁,并继续执行 Warm/Cold Path 逻辑。
|
||||
4. **后续 9个任务**会被阻塞在 `async with lock:` 处,异步等待。
|
||||
5. 第一个任务完成后,它会将结果写入缓存并释放锁。
|
||||
6. 后续 9 个任务依次获取到锁,但它们在获取锁之后会**再次检查缓存**。此时,它们会发现缓存中已有数据,于是直接从缓存返回,不再执行任何网络操作。
|
||||
|
||||
这个模式优雅地解决了并发上传的资源浪费和竞态问题。
|
||||
|
||||
## Part 3: 异步并发与流程编排
|
||||
|
||||
为了在处理复杂请求(例如,包含多个文件的消息)时保持前端的流畅响应,插件大量使用了 `asyncio` 的高级特性。
|
||||
|
||||
**目标**: 最大化 I/O 效率,缩短用户的等待时间。
|
||||
|
||||
#### 3.1 `asyncio.gather`:并发处理所有消息
|
||||
|
||||
`GeminiContentBuilder.build_contents` 方法是并发处理的典范。它没有按顺序循环处理每条消息,而是:
|
||||
1. 为对话历史中的**每一条消息**创建一个 `_process_message_turn` 协程任务。
|
||||
2. 将所有任务放入一个列表。
|
||||
3. 使用 `await asyncio.gather(*tasks)` **同时启动并等待所有任务完成**。
|
||||
|
||||
这意味着,如果一条消息包含 5 个待上传的文件,另一条包含 3 个,这 8 个文件的上传和处理是**并行进行**的,总耗时取决于最慢的那个文件,而不是所有文件耗时的总和。
|
||||
|
||||
#### 3.2 `asyncio.Queue`:解耦的进度汇报
|
||||
|
||||
`UploadStatusManager` 展示了如何通过生产者-消费者模型实现优雅的进度汇报。
|
||||
|
||||
- **生产者 (上传任务)**:
|
||||
- 当一个 `_process_message_turn` 任务确定需要上传文件时,它会向一个共享的 `asyncio.Queue` 中 `put` 一个 `('REGISTER_UPLOAD',)` 元组。
|
||||
- 上传完成后,它会 `put` 一个 `('COMPLETE_UPLOAD',)` 元组。
|
||||
|
||||
- **消费者 (`UploadStatusManager`)**:
|
||||
- 它在一个独立的后台任务 (`asyncio.create_task`) 中运行,循环地从队列中 `get` 消息。
|
||||
- 每当收到 `REGISTER_UPLOAD`,它就将预期总数加一。
|
||||
- 每当收到 `COMPLETE_UPLOAD`,它就将完成数加一。
|
||||
- 每次计数变化后,它会重新计算进度(例如,“正在上传 3/8…”),并通过 `EventEmitter` 发送给前端。
|
||||
|
||||
这种设计将“执行业务逻辑”(上传)和“汇报进度”两个职责完全解耦。上传任务只管“生产”状态事件,进度管理器只管“消费”事件并更新 UI,代码非常清晰。
|
||||
|
||||
## Part 4: 响应处理与前端兼容性
|
||||
|
||||
**目标**: 提供流畅、信息丰富且绝对不会“搞乱”前端页面的用户体验。
|
||||
|
||||
#### 4.1 统一响应处理器 `_unified_response_processor`
|
||||
|
||||
- **问题**: Google API 同时支持流式(streaming)和非流式(non-streaming)两种响应模式,如果为两种模式都写一套处理逻辑,代码会很冗余。
|
||||
- **解决方案**: `pipe` 方法的核心返回部分,无论是哪种模式,最终都会调用 `_unified_response_processor`。
|
||||
- 对于**流式**响应,直接将 API 返回的异步生成器传入。
|
||||
- 对于**非流式**响应,它会先将单个响应对象包装成一个只含一项的简单异步生成器。
|
||||
- **效果**: `_unified_response_processor` 内部只需用一套 `async for` 循环逻辑即可处理所有情况,极大地简化了代码。
|
||||
|
||||
#### 4.2 后置元数据处理 `_do_post_processing`
|
||||
|
||||
- **问题**: 像 Token 使用量 (`usage`)、搜索引用来源 (`sources`) 等信息,只有在整个响应完全生成后才能获得。如果和内容混在一起发送,会影响流式输出的体验。
|
||||
- **解决方案**: `_unified_response_processor` 在主内容流(`choices`)完全结束后,会进入后置处理阶段。它会调用 `_do_post_processing` 来提取这些元数据,并通过 `EventEmitter` 的 `emit_completion` 或 `emit_usage` 方法,作为**独立的、附加的事件**发送给前端。
|
||||
|
||||
#### 4.3 前端兼容性技巧 `_disable_special_tags`
|
||||
|
||||
- **问题**: LLM 很可能在思考过程中生成 `<think>...</think>` 或 `<details>...</details>` 这样的 XML/HTML 风格标签。如果这些文本原样发送到前端,浏览器会尝试将其解析为 HTML 元素,导致页面布局错乱或内容丢失。
|
||||
- **解决方案**: 一个极其巧妙的技巧——在这些特殊标签的开头注入一个**零宽度空格(Zero-Width Space, ZWS, `\u200b`)**。
|
||||
- 例如,将 `<think>` 替换为 `<think>` (后者尖括号后多一个 ZWS)。
|
||||
- 这个改动对人类用户完全不可见,但对于浏览器的 HTML 解析器来说,`<think>` 不再是一个合法的标签名,因此它会被当作纯文本处理,从而保证了前端渲染的绝对安全。
|
||||
- 当需要将这段历史作为上下文发回给模型时,再通过 `_enable_special_tags` 将这些 ZWS 移除,恢复原始文本。
|
||||
|
||||
## Part 5: 与 Open WebUI 和 Google API 的深度集成
|
||||
|
||||
`Gemini Manifold` 充分利用了 Open WebUI 的框架特性和 Google API 的高级功能。
|
||||
|
||||
#### 5.1 `pipes` 方法与模型缓存
|
||||
|
||||
- `pipes()` 方法负责向 Open WebUI 注册所有可用的 Gemini 模型。
|
||||
- 它使用了 `@cached` 装饰器,这意味着对 Google API 的 `list_models` 调用结果会被缓存。只要插件配置(如 API Key, 白名单等)不变,后续的 `pipes` 调用会直接从缓存返回,避免了不必要的网络请求。
|
||||
|
||||
#### 5.2 多源内容处理 (`_genai_parts_from_text`)
|
||||
|
||||
`GeminiContentBuilder` 的核心能力之一是从一段文本中智能地解析出多种类型的内容。
|
||||
- 它使用正则表达式一次性地从用户输入中匹配出 Markdown 图片链接 (`![]()`) 和 YouTube 视频链接。
|
||||
- 对于匹配到的每一种 URI,它都会分派给统一的 `_genai_part_from_uri` 方法处理。
|
||||
- `_genai_part_from_uri` 内部进一步区分 URI 类型(是本地文件、data URI 还是 YouTube 链接),并调用相应的处理器(例如,从数据库读取文件、解码 base64、或解析 YouTube URL 参数)。
|
||||
|
||||
#### 5.3 与 Open WebUI 数据库交互
|
||||
|
||||
为了处理用户上传的文件,插件需要访问 Open WebUI 的内部数据库。
|
||||
- 它通过 `from open_webui.models.files import Files` 导入 `Files` 模型。
|
||||
- 在 `_get_file_data` 方法中,它调用 `Files.get_file_by_id(file_id)` 来获取文件的元数据(如存储路径、MIME 类型)。
|
||||
- **关键点**: 由于数据库 API 是同步阻塞的,插件明智地使用了 `await asyncio.to_thread(Files.get_file_by_id, file_id)`,将同步调用放入一个独立的线程中执行,从而避免了对主异步事件循环的阻塞。
|
||||
|
||||
## 总结
|
||||
|
||||
`Gemini Manifold` 是一个教科书级别的 Open WebUI `Pipe` 插件。它展示了超越简单 API 调用的高级插件应该具备的特质:
|
||||
- **架构思维**: 通过职责分离的类和清晰的流程编排来管理复杂性。
|
||||
- **性能意识**: 在所有 I/O 密集型操作中,都将性能优化(缓存、并发)放在首位。
|
||||
- **用户为本**: 通过丰富的、非阻塞的实时反馈,极大地提升了用户体验。
|
||||
- **健壮与安全**: 通过精巧的技巧和周密的错误处理,确保插件在各种异常情况下都能稳定运行。
|
||||
|
||||
对于任何希望超越基础,构建企业级、高性能 Open WebUI 插件的开发者而言,`Gemini Manifold` 的每一行代码都值得细细品味。
|
||||
7
docs/features/plugin/development/_category_.json
Normal file
7
docs/features/plugin/development/_category_.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Development",
|
||||
"position": 800,
|
||||
"link": {
|
||||
"type": "generated-index"
|
||||
}
|
||||
}
|
||||
424
docs/features/plugin/development/events.mdx
Normal file
424
docs/features/plugin/development/events.mdx
Normal file
@@ -0,0 +1,424 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "Events"
|
||||
---
|
||||
|
||||
# 🔔 Events: Using `__event_emitter__` and `__event_call__` in Open WebUI
|
||||
|
||||
Open WebUI's plugin architecture is not just about processing input and producing output—**it's about real-time, interactive communication with the UI and users**. To make your Tools, Functions, and Pipes more dynamic, Open WebUI provides a built-in event system via the `__event_emitter__` and `__event_call__` helpers.
|
||||
|
||||
This guide explains **what events are**, **how you can trigger them** from your code, and **the full catalog of event types** you can use (including much more than just `"input"`).
|
||||
|
||||
---
|
||||
|
||||
## 🌊 What Are Events?
|
||||
|
||||
**Events** are real-time notifications or interactive requests sent from your backend code (Tool, or Function) to the web UI. They allow you to update the chat, display notifications, request confirmation, run UI flows, and more.
|
||||
|
||||
- Events are sent using the `__event_emitter__` helper for one-way updates, or `__event_call__` when you need user input or a response (e.g., confirmation, input, etc.).
|
||||
|
||||
**Metaphor:**
|
||||
Think of Events like push notifications and modal dialogs that your plugin can trigger, making the chat experience richer and more interactive.
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Basic Usage
|
||||
|
||||
### Sending an Event
|
||||
|
||||
You can trigger an event anywhere inside your Tool, or Function by calling:
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status", # See the event types list below
|
||||
"data": {
|
||||
"description": "Processing started!",
|
||||
"done": False,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
You **do not** need to manually add fields like `chat_id` or `message_id`—these are handled automatically by Open WebUI.
|
||||
|
||||
### Interactive Events
|
||||
|
||||
When you need to pause execution until the user responds (e.g., confirm/cancel dialogs, code execution, or input), use `__event_call__`:
|
||||
|
||||
```python
|
||||
result = await __event_call__(
|
||||
{
|
||||
"type": "input", # Or "confirmation", "execute"
|
||||
"data": {
|
||||
"title": "Please enter your password",
|
||||
"message": "Password is required for this action",
|
||||
"placeholder": "Your password here",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# result will contain the user's input value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 Event Payload Structure
|
||||
|
||||
When you emit or call an event, the basic structure is:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event_type", // See full list below
|
||||
"data": { ... } // Event-specific payload
|
||||
}
|
||||
```
|
||||
|
||||
Most of the time, you only set `"type"` and `"data"`. Open WebUI fills in the routing automatically.
|
||||
|
||||
---
|
||||
|
||||
## 🗂 Full List of Event Types
|
||||
|
||||
Below is a comprehensive table of **all supported `type` values** for events, along with their intended effect and data structure. (This is based on up-to-date analysis of Open WebUI event handling logic.)
|
||||
|
||||
| type | When to use | Data payload structure (examples) |
|
||||
| -------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `status` | Show a status update/history for a message | `{description: ..., done: bool, hidden: bool}` |
|
||||
| `chat:completion` | Provide a chat completion result | (Custom, see Open WebUI internals) |
|
||||
| `chat:message:delta`,<br/>`message` | Append content to the current message | `{content: "text to append"}` |
|
||||
| `chat:message`,<br/>`replace` | Replace current message content completely | `{content: "replacement text"}` |
|
||||
| `chat:message:files`,<br/>`files` | Set or overwrite message files (for uploads, output) | `{files: [...]}` |
|
||||
| `chat:title` | Set (or update) the chat conversation title | Topic string OR `{title: ...}` |
|
||||
| `chat:tags` | Update the set of tags for a chat | Tag array or object |
|
||||
| `source`,<br/>`citation` | Add a source/citation, or code execution result | For code: See [below.](/features/plugin/development/events#source-or-citation-and-code-execution) |
|
||||
| `notification` | Show a notification ("toast") in the UI | `{type: "info" or "success" or "error" or "warning", content: "..."}` |
|
||||
| `confirmation` <br/>(needs `__event_call__`) | Ask for confirmation (OK/Cancel dialog) | `{title: "...", message: "..."}` |
|
||||
| `input` <br/>(needs `__event_call__`) | Request simple user input ("input box" dialog) | `{title: "...", message: "...", placeholder: "...", value: ...}` |
|
||||
| `execute` <br/>(needs `__event_call__`) | Request user-side code execution and return result | `{code: "...javascript code..."}` | |
|
||||
|
||||
**Other/Advanced types:**
|
||||
|
||||
- You can define your own types and handle them at the UI layer (or use upcoming event-extension mechanisms).
|
||||
|
||||
### ❗ Details on Specific Event Types
|
||||
|
||||
### `status`
|
||||
|
||||
Show a status/progress update in the UI:
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Step 1/3: Fetching data...",
|
||||
"done": False,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `chat:message:delta` or `message`
|
||||
|
||||
**Streaming output** (append text):
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "chat:message:delta", # or simply "message"
|
||||
"data": {
|
||||
"content": "Partial text, "
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Later, as you generate more:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "chat:message:delta",
|
||||
"data": {
|
||||
"content": "next chunk of response."
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `chat:message` or `replace`
|
||||
|
||||
**Set (or replace) the entire message content:**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "chat:message", # or "replace"
|
||||
"data": {
|
||||
"content": "Final, complete response."
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `files` or `chat:message:files`
|
||||
|
||||
**Attach or update files:**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "files", # or "chat:message:files"
|
||||
"data": {
|
||||
"files": [
|
||||
# Open WebUI File Objects
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `chat:title`
|
||||
|
||||
**Update the chat's title:**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "chat:title",
|
||||
"data": {
|
||||
"title": "Market Analysis Bot Session"
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `chat:tags`
|
||||
|
||||
**Update the chat's tags:**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "chat:tags",
|
||||
"data": {
|
||||
"tags": ["finance", "AI", "daily-report"]
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `source` or `citation` (and code execution)
|
||||
|
||||
**Add a reference/citation:**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "source", # or "citation"
|
||||
"data": {
|
||||
# Open WebUI Source (Citation) Object
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**For code execution (track execution state):**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "source",
|
||||
"data": {
|
||||
# Open WebUI Code Source (Citation) Object
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `notification`
|
||||
|
||||
**Show a toast notification:**
|
||||
|
||||
```python
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info", # "success", "warning", "error"
|
||||
"content": "The operation completed successfully!"
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `confirmation` (**requires** `__event_call__`)
|
||||
|
||||
**Show a confirm dialog and get user response:**
|
||||
|
||||
```python
|
||||
result = await __event_call__(
|
||||
{
|
||||
"type": "confirmation",
|
||||
"data": {
|
||||
"title": "Are you sure?",
|
||||
"message": "Do you really want to proceed?"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if result: # or check result contents
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "success", "content": "User confirmed operation."}
|
||||
})
|
||||
else:
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": "User cancelled."}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `input` (**requires** `__event_call__`)
|
||||
|
||||
**Prompt user for text input:**
|
||||
|
||||
```python
|
||||
result = await __event_call__(
|
||||
{
|
||||
"type": "input",
|
||||
"data": {
|
||||
"title": "Enter your name",
|
||||
"message": "We need your name to proceed.",
|
||||
"placeholder": "Your full name"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
user_input = result
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "info", "content": f"You entered: {user_input}"}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `execute` (**requires** `__event_call__`)
|
||||
|
||||
**Run code dynamically on the user's side:**
|
||||
|
||||
```python
|
||||
result = await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": "print(40 + 2);",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"Code executed, result: {result}"
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ When & Where to Use Events
|
||||
|
||||
- **From any Tool, or Function** in Open WebUI.
|
||||
- To **stream responses**, show progress, request user data, update the UI, or display supplementary info/files.
|
||||
- `await __event_emitter__` is for one-way messages (fire and forget).
|
||||
- `await __event_call__` is for when you need a response from the user (input, execute, confirmation).
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips & Advanced Notes
|
||||
|
||||
- **Multiple types per message:** You can emit several events of different types for one message—for example, show `status` updates, then stream with `chat:message:delta`, then complete with a `chat:message`.
|
||||
- **Custom event types:** While the above list is the standard, you may use your own types and detect/handle them in custom UI code.
|
||||
- **Extensibility:** The event system is designed to evolve—always check the [Open WebUI documentation](https://github.com/open-webui/open-webui) for the most current list and advanced usage.
|
||||
|
||||
---
|
||||
|
||||
## 🧐 FAQ
|
||||
|
||||
### Q: How do I trigger a notification for the user?
|
||||
Use `notification` type:
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "success", "content": "Task complete"}
|
||||
})
|
||||
```
|
||||
|
||||
### Q: How do I prompt the user for input and get their answer?
|
||||
Use:
|
||||
```python
|
||||
response = await __event_call__({
|
||||
"type": "input",
|
||||
"data": {
|
||||
"title": "What's your name?",
|
||||
"message": "Please enter your preferred name:",
|
||||
"placeholder": "Name"
|
||||
}
|
||||
})
|
||||
|
||||
# response will be: {"value": "user's answer"}
|
||||
```
|
||||
|
||||
### Q: What event types are available for `__event_call__`?
|
||||
- `"input"`: Input box dialog
|
||||
- `"confirmation"`: Yes/No, OK/Cancel dialog
|
||||
- `"execute"`: Run provided code on client and return result
|
||||
|
||||
### Q: Can I update files attached to a message?
|
||||
Yes—use the `"files"` or `"chat:message:files"` event type with a `{files: [...]}` payload.
|
||||
|
||||
### Q: Can I update the conversation title or tags?
|
||||
Absolutely: use `"chat:title"` or `"chat:tags"` accordingly.
|
||||
|
||||
### Q: Can I stream responses (partial tokens) to the user?
|
||||
Yes—emit `"chat:message:delta"` events in a loop, then finish with `"chat:message"`.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Conclusion
|
||||
|
||||
**Events** give you real-time, interactive superpowers inside Open WebUI. They let your code update content, trigger notifications, request user input, stream results, handle code, and much more—seamlessly plugging your backend intelligence into the chat UI.
|
||||
|
||||
- Use `__event_emitter__` for one-way status/content updates.
|
||||
- Use `__event_call__` for interactions that require user follow-up (input, confirmation, execution).
|
||||
|
||||
Refer to this document for common event types and structures, and explore Open WebUI source code or docs for breaking updates or custom events!
|
||||
|
||||
---
|
||||
|
||||
**Happy event-driven coding in Open WebUI! 🚀**
|
||||
340
docs/features/plugin/development/reserved-args.mdx
Normal file
340
docs/features/plugin/development/reserved-args.mdx
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
sidebar_position: 999
|
||||
title: "Reserved Arguments"
|
||||
---
|
||||
|
||||
:::warning
|
||||
|
||||
This tutorial is a community contribution and is not supported by the Open WebUI team. It serves only as a demonstration on how to customize Open WebUI for your specific use case. Want to contribute? Check out the contributing tutorial.
|
||||
|
||||
:::
|
||||
|
||||
# 🪄 Special Arguments
|
||||
|
||||
When developping your own `Tools`, `Functions` (`Filters`, `Pipes` or `Actions`), `Pipelines` etc, you can use special arguments explore the full spectrum of what Open-WebUI has to offer.
|
||||
|
||||
This page aims to detail the type and structure of each special argument as well as provide an example.
|
||||
|
||||
### `body`
|
||||
|
||||
A `dict` usually destined to go almost directly to the model. Although it is not strictly a special argument, it is included here for easier reference and because it contains itself some special arguments.
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
"stream": true,
|
||||
"model": "my-cool-model",
|
||||
# lowercase string with - separated words: this is the ID of the model
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "What is in this picture?"
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAdYAAAGcCAYAAABk2YF[REDACTED]"
|
||||
# Images are passed as base64 encoded data
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "The image appears to be [REDACTED]"
|
||||
},
|
||||
],
|
||||
"features": {
|
||||
"image_generation": false,
|
||||
"code_interpreter": false,
|
||||
"web_search": false
|
||||
},
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"metadata": "[The exact same dict as __metadata__]",
|
||||
"files": "[The exact same list as __files__]"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### `__user__`
|
||||
|
||||
A `dict` with user information.
|
||||
|
||||
Note that if the `UserValves` class is defined, its instance has to be accessed via `__user__["valves"]`. Otherwise, the `valves` keyvalue is missing entirely from `__user__`.
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"email": "cheesy_dude@openwebui.com",
|
||||
"name": "Patrick",
|
||||
"role": "user",
|
||||
# role can be either `user` or `admin`
|
||||
"valves": "[the UserValve instance]"
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### `__metadata__`
|
||||
|
||||
A `dict` with wide ranging information about the chat, model, files, etc.
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"chat_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"message_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"session_id": "xxxxxxxxxxxxxxxxxxxx",
|
||||
"tool_ids": null,
|
||||
# tool_ids is a list of str.
|
||||
"tool_servers": [],
|
||||
"files": "[Same as in body['files']]",
|
||||
# If no files are given, the files key exists in __metadata__ and its value is []
|
||||
"features": {
|
||||
"image_generation": false,
|
||||
"code_interpreter": false,
|
||||
"web_search": false
|
||||
},
|
||||
"variables": {
|
||||
"{{USER_NAME}}": "cheesy_username",
|
||||
"{{USER_LOCATION}}": "Unknown",
|
||||
"{{CURRENT_DATETIME}}": "2025-02-02 XX:XX:XX",
|
||||
"{{CURRENT_DATE}}": "2025-02-02",
|
||||
"{{CURRENT_TIME}}": "XX:XX:XX",
|
||||
"{{CURRENT_WEEKDAY}}": "Monday",
|
||||
"{{CURRENT_TIMEZONE}}": "Europe/Berlin",
|
||||
"{{USER_LANGUAGE}}": "en-US"
|
||||
},
|
||||
"model": "[The exact same dict as __model__]",
|
||||
"direct": false,
|
||||
"function_calling": "native",
|
||||
"type": "user_response",
|
||||
"interface": "open-webui"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### `__model__`
|
||||
|
||||
A `dict` with information about the model.
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-cool-model",
|
||||
"name": "My Cool Model",
|
||||
"object": "model",
|
||||
"created": 1746000000,
|
||||
"owned_by": "openai",
|
||||
# either openai or ollama
|
||||
"info": {
|
||||
"id": "my-cool-model",
|
||||
"user_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"base_model_id": "gpt-4o",
|
||||
# this is the name of model that the model endpoint serves
|
||||
"name": "My Cool Model",
|
||||
"params": {
|
||||
"system": "You are my best assistant. You answer [REDACTED]",
|
||||
"function_calling": "native"
|
||||
# custom options appear here, for example "Top K"
|
||||
},
|
||||
"meta": {
|
||||
"profile_image_url": "/static/favicon.png",
|
||||
"description": "Description of my-cool-model",
|
||||
"capabilities": {
|
||||
"vision": true,
|
||||
"usage": true,
|
||||
"citations": true
|
||||
},
|
||||
"position": 17,
|
||||
"tags": [
|
||||
{
|
||||
"name": "for_friends"
|
||||
},
|
||||
{
|
||||
"name": "vision_enabled"
|
||||
}
|
||||
],
|
||||
"suggestion_prompts": null
|
||||
},
|
||||
"access_control": {
|
||||
"read": {
|
||||
"group_ids": [],
|
||||
"user_ids": []
|
||||
},
|
||||
"write": {
|
||||
"group_ids": [],
|
||||
"user_ids": []
|
||||
}
|
||||
},
|
||||
"is_active": true,
|
||||
"updated_at": 1740000000,
|
||||
"created_at": 1740000000
|
||||
},
|
||||
"preset": true,
|
||||
"actions": [],
|
||||
"tags": [
|
||||
{
|
||||
"name": "for_friends"
|
||||
},
|
||||
{
|
||||
"name": "vision_enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### `__messages__`
|
||||
|
||||
A `list` of the previous messages.
|
||||
|
||||
See the `body["messages"]` value above.
|
||||
|
||||
### `__chat_id__`
|
||||
|
||||
The `str` of the `chat_id`.
|
||||
|
||||
See the `__metadata__["chat_id"]` value above.
|
||||
|
||||
### `__session_id__`
|
||||
|
||||
The `str` of the `session_id`.
|
||||
|
||||
See the `__metadata__["session_id"]` value above.
|
||||
|
||||
### `__message_id__`
|
||||
|
||||
The `str` of the `message_id`.
|
||||
|
||||
See the `__metadata__["message_id"]` value above.
|
||||
|
||||
### `__event_emitter__`
|
||||
|
||||
A `Callable` used to display event information to the user.
|
||||
|
||||
### `__event_call__`
|
||||
|
||||
A `Callable` used for `Actions`.
|
||||
|
||||
### `__files__`
|
||||
|
||||
A `list` of files sent via the chat. Note that images are not considered files and are sent directly to the model as part of the `body["messages"]` list.
|
||||
|
||||
The actual binary of the file is not part of the arguments for performance reason, but the file remain nonetheless accessible by its path if needed. For example using `docker` the python syntax for the path could be:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
the_file = Path(f"/app/backend/data/uploads/{__files__[0]["files"]["id"]}_{__files__[0]["files"]["filename"]}")
|
||||
assert the_file.exists()
|
||||
```
|
||||
|
||||
Note that the same files dict can also be accessed via `__metadata__["files"]` (and its value is `[]` if no files are sent) or via `body["files"]` (but the `files` key is missing entirely from `body` if no files are sent).
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
```json
|
||||
|
||||
[
|
||||
{
|
||||
"type": "file",
|
||||
"file": {
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"filename": "Napoleon - Wikipedia.pdf",
|
||||
"user_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"hash": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"data": {
|
||||
"content": "Napoleon - Wikipedia\n\n\nNapoleon I\n\nThe Emperor Napoleon in His Study at the\nTuileries, 1812\n\nEmperor of the French\n\n1st reign 18 May 1804 – 6 April 1814\n\nSuccessor Louis XVIII[a]\n\n2nd reign 20 March 1815 – 22 June 1815\n\nSuccessor Louis XVIII[a]\n\nFirst Consul of the French Republic\n\nIn office\n13 December 1799 – 18 May 1804\n\nBorn Napoleone Buonaparte\n15 August 1769\nAjaccio, Corsica, Kingdom of\nFrance\n\nDied 5 May 1821 (aged 51)\nLongwood, Saint Helena\n\nBurial 15 December 1840\nLes Invalides, Paris\n\nNapoleon\nNapoleon Bonaparte[b] (born Napoleone\nBuonaparte;[1][c] 15 August 1769 – 5 May 1821), later\nknown [REDACTED]",
|
||||
# The content value is the output of the document parser, the above example is with Tika as a document parser
|
||||
},
|
||||
"meta": {
|
||||
"name": "Napoleon - Wikipedia.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size": 10486578,
|
||||
# in bytes, here about 10Mb
|
||||
"data": {},
|
||||
"collection_name": "file-96xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
# always begins by 'file'
|
||||
},
|
||||
"created_at": 1740000000,
|
||||
"updated_at": 1740000000
|
||||
},
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"url": "/api/v1/files/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "Napoleon - Wikipedia.pdf",
|
||||
"collection_name": "file-96xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
"status": "uploaded",
|
||||
"size": 10486578,
|
||||
"error": "",
|
||||
"itemId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
# itemId is not the same as file["id"]
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### `__request__`
|
||||
|
||||
An instance of `fastapi.Request`. You can read more in the [migration page](/docs/features/plugin/migration/index.mdx) or in [fastapi's documentation](https://fastapi.tiangolo.com/reference/request/).
|
||||
|
||||
### `__task__`
|
||||
|
||||
A `str` for the type of task. Its value is just a shorthand for `__metadata__["task"]` if present, otherwise `None`.
|
||||
|
||||
<details>
|
||||
<summary>Possible values</summary>
|
||||
|
||||
```json
|
||||
|
||||
[
|
||||
"title_generation",
|
||||
"tags_generation",
|
||||
"emoji_generation",
|
||||
"query_generation",
|
||||
"image_prompt_generation",
|
||||
"autocomplete_generation",
|
||||
"function_calling",
|
||||
"moa_response_generation"
|
||||
]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### `__task_body__`
|
||||
|
||||
A `dict` containing the `body` needed to accomplish a given `__task__`. Its value is just a shorthand for `__metadata__["task_body"]` if present, otherwise `None`.
|
||||
|
||||
Its structure is the same as `body` above, with modifications like using the appropriate model and system message etc.
|
||||
|
||||
### `__tools__`
|
||||
|
||||
A `list` of `ToolUserModel` instances.
|
||||
|
||||
For details the attributes of `ToolUserModel` instances, the code can be found in [tools.py](https://github.com/open-webui/open-webui/blob/main/backend/open_webui/models/tools.py).
|
||||
|
||||
77
docs/features/plugin/development/valves.mdx
Normal file
77
docs/features/plugin/development/valves.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "Valves"
|
||||
---
|
||||
|
||||
## Valves
|
||||
|
||||
Valves and UserValves are used to allow users to provide dynamic details such as an API key or a configuration option. These will create a fillable field or a bool switch in the GUI menu for the given function. They are always optional, but HIGHLY encouraged.
|
||||
|
||||
Hence, Valves and UserValves class can be defined in either a `Pipe`, `Pipeline`, `Filter` or `Tools` class.
|
||||
|
||||
Valves are configurable by admins alone via the Tools or Functions menus. On the other hand UserValves are configurable by any users directly from a chat session.
|
||||
|
||||
<details>
|
||||
<summary>Commented example</summary>
|
||||
|
||||
```python
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
# Define and Valves
|
||||
class Filter:
|
||||
# Notice the current indentation: Valves and UserValves must be declared as
|
||||
# attributes of a Tools, Filter or Pipe class. Here we take the
|
||||
# example of a Filter.
|
||||
class Valves(BaseModel):
|
||||
# Valves and UserValves inherit from pydantic's BaseModel. This
|
||||
# enables complex use cases like model validators etc.
|
||||
test_valve: int = Field( # Notice the type hint: it is used to
|
||||
# choose the kind of UI element to show the user (buttons,
|
||||
# texts, etc).
|
||||
default=4,
|
||||
description="A valve controlling a numberical value"
|
||||
# required=False, # you can enforce fields using True
|
||||
)
|
||||
# To give the user the choice between multiple strings, you can use Literal from typing:
|
||||
choice_option: Literal["choiceA", "choiceB"] = Field(
|
||||
default="choiceA",
|
||||
description="An example of a multi choice valve",
|
||||
)
|
||||
priority: int = Field(
|
||||
default=0,
|
||||
description="Priority level for the filter operations. Lower values are passed through first"
|
||||
)
|
||||
# The priority field is optional but if present will be used to
|
||||
# order the Filters.
|
||||
pass
|
||||
# Note that this 'pass' helps for parsing and is recommended.
|
||||
|
||||
# UserValves are defined the same way.
|
||||
class UserValves(BaseModel):
|
||||
test_user_valve: bool = Field(
|
||||
default=False, description="A user valve controlling a True/False (on/off) switch"
|
||||
)
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
# Because they are set by the admin, they are accessible directly
|
||||
# upon code execution.
|
||||
pass
|
||||
|
||||
# The inlet method is only used for Filter but the __user__ handling is the same
|
||||
def inlet(self, body: dict, __user__: dict):
|
||||
# Because UserValves are defined per user they are only available
|
||||
# on use.
|
||||
# Note that although __user__ is a dict, __user__["valves"] is a
|
||||
# UserValves object. Hence you can access values like that:
|
||||
test_user_valve = __user__["valves"].test_user_valve
|
||||
# Or:
|
||||
test_user_valve = dict(__user__["valves"])["test_user_valve"]
|
||||
# But this will return the default value instead of the actual value:
|
||||
# test_user_valve = __user__["valves"]["test_user_valve"] # Do not do that!
|
||||
```
|
||||
|
||||
</details>
|
||||
316
docs/features/plugin/functions/action.mdx
Normal file
316
docs/features/plugin/functions/action.mdx
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "Action Function"
|
||||
---
|
||||
|
||||
Action functions allow you to write custom buttons that appear in the message toolbar for end users to interact with. This feature enables more interactive messaging, allowing users to grant permission before a task is performed, generate visualizations of structured data, download an audio snippet of chats, and many other use cases.
|
||||
|
||||
Actions are admin-managed functions that extend the chat interface with custom interactive capabilities. When a message is generated by a model that has actions configured, these actions appear as clickable buttons beneath the message.
|
||||
|
||||
A scaffold of Action code can be found [in the community section](https://openwebui.com/f/hub/custom_action/). For more Action Function examples built by the community, visit [https://openwebui.com/functions](https://openwebui.com/functions).
|
||||
|
||||
An example of a graph visualization Action can be seen in the video below.
|
||||
|
||||
<div align="center">
|
||||
<a href="#">
|
||||
<img
|
||||
src="/images/pipelines/graph-viz-action.gif"
|
||||
alt="Graph Visualization Action"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Action Function Architecture
|
||||
|
||||
Actions are Python-based functions that integrate directly into the chat message toolbar. They execute server-side and can interact with users through real-time events, modify message content, and access the full Open WebUI context.
|
||||
|
||||
### Function Structure
|
||||
|
||||
Actions follow a specific class structure with an `action` method as the main entry point:
|
||||
|
||||
```python
|
||||
class Action:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
class Valves(BaseModel):
|
||||
# Configuration parameters
|
||||
parameter_name: str = "default_value"
|
||||
|
||||
async def action(self, body: dict, __user__=None, __event_emitter__=None, __event_call__=None):
|
||||
# Action implementation
|
||||
return {"content": "Modified message content"}
|
||||
```
|
||||
|
||||
### Action Method Parameters
|
||||
|
||||
The `action` method receives several parameters that provide access to the execution context:
|
||||
|
||||
- **`body`** - Dictionary containing the message data and context
|
||||
- **`__user__`** - Current user object with permissions and settings
|
||||
- **`__event_emitter__`** - Function to send real-time updates to the frontend
|
||||
- **`__event_call__`** - Function for bidirectional communication (confirmations, inputs)
|
||||
- **`__model__`** - Model information that triggered the action
|
||||
- **`__request__`** - FastAPI request object for accessing headers, etc.
|
||||
- **`__id__`** - Action ID (useful for multi-action functions)
|
||||
|
||||
## Event System Integration
|
||||
|
||||
Actions can utilize Open WebUI's real-time event system for interactive experiences:
|
||||
|
||||
### Event Emitter (`__event_emitter__`)
|
||||
|
||||
**For more information about Events and Event emitters, see [Events and Event Emitters](https://docs.openwebui.com/features/plugin/events/).**
|
||||
|
||||
Send real-time updates to the frontend during action execution:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict, __event_emitter__=None):
|
||||
# Send status updates
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {"description": "Processing request..."}
|
||||
})
|
||||
|
||||
# Send notifications
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "info", "content": "Action completed successfully"}
|
||||
})
|
||||
```
|
||||
|
||||
### Event Call (`__event_call__`)
|
||||
Request user input or confirmation during execution:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict, __event_call__=None):
|
||||
# Request user confirmation
|
||||
response = await __event_call__({
|
||||
"type": "confirmation",
|
||||
"data": {
|
||||
"title": "Confirm Action",
|
||||
"message": "Are you sure you want to proceed?"
|
||||
}
|
||||
})
|
||||
|
||||
# Request user input
|
||||
user_input = await __event_call__({
|
||||
"type": "input",
|
||||
"data": {
|
||||
"title": "Enter Value",
|
||||
"message": "Please provide additional information:",
|
||||
"placeholder": "Type your input here..."
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Action Types and Configurations
|
||||
|
||||
### Single Actions
|
||||
Standard actions with one `action` method:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict, **kwargs):
|
||||
# Single action implementation
|
||||
return {"content": "Action result"}
|
||||
```
|
||||
|
||||
### Multi-Actions
|
||||
Functions can define multiple sub-actions through an `actions` array:
|
||||
|
||||
```python
|
||||
actions = [
|
||||
{
|
||||
"id": "summarize",
|
||||
"name": "Summarize",
|
||||
"icon_url": "data:image/svg+xml;base64,..."
|
||||
},
|
||||
{
|
||||
"id": "translate",
|
||||
"name": "Translate",
|
||||
"icon_url": "data:image/svg+xml;base64,..."
|
||||
}
|
||||
]
|
||||
|
||||
async def action(self, body: dict, __id__=None, **kwargs):
|
||||
if __id__ == "summarize":
|
||||
# Summarization logic
|
||||
return {"content": "Summary: ..."}
|
||||
elif __id__ == "translate":
|
||||
# Translation logic
|
||||
return {"content": "Translation: ..."}
|
||||
```
|
||||
|
||||
### Global vs Model-Specific Actions
|
||||
- **Global Actions** - Turn on the toggle in the Action's settings, to globally enable it for all users and all models.
|
||||
- **Model-Specific Actions** - Configure enabled actions for specific models in the model settings.
|
||||
|
||||
## Advanced Capabilities
|
||||
|
||||
### Background Task Execution
|
||||
For long-running operations, actions can integrate with the task system:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict, __event_emitter__=None):
|
||||
# Start long-running process
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {"description": "Starting background processing..."}
|
||||
})
|
||||
|
||||
# Perform time-consuming operation
|
||||
result = await some_long_running_function()
|
||||
|
||||
return {"content": f"Processing completed: {result}"}
|
||||
```
|
||||
|
||||
### File and Media Handling
|
||||
Actions can work with uploaded files and generate new media:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict):
|
||||
message = body
|
||||
|
||||
# Access uploaded files
|
||||
if message.get("files"):
|
||||
for file in message["files"]:
|
||||
# Process file based on type
|
||||
if file["type"] == "image":
|
||||
# Image processing logic
|
||||
pass
|
||||
|
||||
# Return new files
|
||||
return {
|
||||
"content": "Analysis complete",
|
||||
"files": [
|
||||
{
|
||||
"type": "image",
|
||||
"url": "generated_chart.png",
|
||||
"name": "Analysis Chart"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### User Context and Permissions
|
||||
Actions can access user information and respect permissions:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict, __user__=None):
|
||||
if __user__["role"] != "admin":
|
||||
return {"content": "This action requires admin privileges"}
|
||||
|
||||
user_name = __user__["name"]
|
||||
return {"content": f"Hello {user_name}, admin action completed"}
|
||||
```
|
||||
|
||||
## Example - Specifying Action Frontmatter
|
||||
|
||||
Each Action function can include a docstring at the top to define metadata for the button. This helps customize the display and behavior of your Action in Open WebUI.
|
||||
|
||||
Example of supported frontmatter fields:
|
||||
- `title`: Display name of the Action.
|
||||
- `author`: Name of the creator.
|
||||
- `version`: Version number of the Action.
|
||||
- `required_open_webui_version`: Minimum compatible version of Open WebUI.
|
||||
- `icon_url (optional)`: URL or Base64 string for a custom icon.
|
||||
|
||||
**Base64-Encoded Example:**
|
||||
|
||||
<details>
|
||||
<summary>Example</summary>
|
||||
|
||||
```python
|
||||
"""
|
||||
title: Enhanced Message Processor
|
||||
author: @admin
|
||||
version: 1.2.0
|
||||
required_open_webui_version: 0.5.0
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCA5TDEzLjA5IDE1Ljc0TDEyIDIyTDEwLjkxIDE1Ljc0TDQgOUwxMC45MSA4LjI2TDEyIDJaIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHN2Zz4K
|
||||
requirements: requests,beautifulsoup4
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Action:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
class Valves(BaseModel):
|
||||
api_key: str = ""
|
||||
processing_mode: str = "standard"
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_emitter__=None,
|
||||
__event_call__=None,
|
||||
):
|
||||
# Send initial status
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {"description": "Processing message..."}
|
||||
})
|
||||
|
||||
# Get user confirmation
|
||||
response = await __event_call__({
|
||||
"type": "confirmation",
|
||||
"data": {
|
||||
"title": "Process Message",
|
||||
"message": "Do you want to enhance this message?"
|
||||
}
|
||||
})
|
||||
|
||||
if not response:
|
||||
return {"content": "Action cancelled by user"}
|
||||
|
||||
# Process the message
|
||||
original_content = body.get("content", "")
|
||||
enhanced_content = f"Enhanced: {original_content}"
|
||||
|
||||
return {"content": enhanced_content}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Error Handling
|
||||
Always implement proper error handling in your actions:
|
||||
|
||||
```python
|
||||
async def action(self, body: dict, __event_emitter__=None):
|
||||
try:
|
||||
# Action logic here
|
||||
result = perform_operation()
|
||||
return {"content": f"Success: {result}"}
|
||||
except Exception as e:
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"Action failed: {str(e)}"}
|
||||
})
|
||||
return {"content": "Action encountered an error"}
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
- Use async/await for I/O operations
|
||||
- Implement timeouts for external API calls
|
||||
- Provide progress updates for long-running operations
|
||||
- Consider using background tasks for heavy processing
|
||||
|
||||
### User Experience
|
||||
- Always provide clear feedback through event emitters
|
||||
- Use confirmation dialogs for destructive actions
|
||||
- Include helpful error messages
|
||||
|
||||
## Integration with Open WebUI Features
|
||||
|
||||
Actions integrate seamlessly with other Open WebUI features:
|
||||
- **Models** - Actions can be model-specific or global
|
||||
- **Tools** - Actions can invoke external tools and APIs
|
||||
- **Files** - Actions can process uploaded files and generate new ones
|
||||
- **Memory** - Actions can access conversation history and context
|
||||
- **Permissions** - Actions respect user roles and access controls
|
||||
|
||||
For more examples and community-contributed actions, visit [https://openwebui.com/functions](https://openwebui.com/functions) where you can discover, download, and explore custom functions built by the Open WebUI community.
|
||||
423
docs/features/plugin/functions/filter.mdx
Normal file
423
docs/features/plugin/functions/filter.mdx
Normal file
@@ -0,0 +1,423 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: "Filter Function"
|
||||
---
|
||||
|
||||
# 🪄 Filter Function: Modify Inputs and Outputs
|
||||
|
||||
Welcome to the comprehensive guide on Filter Functions in Open WebUI! Filters are a flexible and powerful **plugin system** for modifying data *before it's sent to the Large Language Model (LLM)* (input) or *after it’s returned from the LLM* (output). Whether you’re transforming inputs for better context or cleaning up outputs for improved readability, **Filter Functions** let you do it all.
|
||||
|
||||
This guide will break down **what Filters are**, how they work, their structure, and everything you need to know to build powerful and user-friendly filters of your own. Let’s dig in, and don’t worry—I’ll use metaphors, examples, and tips to make everything crystal clear! 🌟
|
||||
|
||||
---
|
||||
|
||||
## 🌊 What Are Filters in Open WebUI?
|
||||
|
||||
Imagine Open WebUI as a **stream of water** flowing through pipes:
|
||||
|
||||
- **User inputs** and **LLM outputs** are the water.
|
||||
- **Filters** are the **water treatment stages** that clean, modify, and adapt the water before it reaches the final destination.
|
||||
|
||||
Filters sit in the middle of the flow—like checkpoints—where you decide what needs to be adjusted.
|
||||
|
||||
Here’s a quick summary of what Filters do:
|
||||
|
||||
1. **Modify User Inputs (Inlet Function)**: Tweak the input data before it reaches the AI model. This is where you enhance clarity, add context, sanitize text, or reformat messages to match specific requirements.
|
||||
2. **Intercept Model Outputs (Stream Function)**: Capture and adjust the AI’s responses **as they’re generated** by the model. This is useful for real-time modifications, like filtering out sensitive information or formatting the output for better readability.
|
||||
3. **Modify Model Outputs (Outlet Function)**: Adjust the AI's response **after it’s processed**, before showing it to the user. This can help refine, log, or adapt the data for a cleaner user experience.
|
||||
|
||||
> **Key Concept:** Filters are not standalone models but tools that enhance or transform the data traveling *to* and *from* models.
|
||||
|
||||
Filters are like **translators or editors** in the AI workflow: you can intercept and change the conversation without interrupting the flow.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Structure of a Filter Function: The Skeleton
|
||||
|
||||
Let's start with the simplest representation of a Filter Function. Don't worry if some parts feel technical at first—we’ll break it all down step by step!
|
||||
|
||||
### 🦴 Basic Skeleton of a Filter
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class Filter:
|
||||
# Valves: Configuration options for the filter
|
||||
class Valves(BaseModel):
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
# Initialize valves (optional configuration for the Filter)
|
||||
self.valves = self.Valves()
|
||||
|
||||
def inlet(self, body: dict) -> dict:
|
||||
# This is where you manipulate user inputs.
|
||||
print(f"inlet called: {body}")
|
||||
return body
|
||||
|
||||
def stream(self, event: dict) -> dict:
|
||||
# This is where you modify streamed chunks of model output.
|
||||
print(f"stream event: {event}")
|
||||
return event
|
||||
|
||||
def outlet(self, body: dict) -> None:
|
||||
# This is where you manipulate model outputs.
|
||||
print(f"outlet called: {body}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🆕 🧲 Toggle Filter Example: Adding Interactivity and Icons (New in Open WebUI 0.6.10)
|
||||
|
||||
Filters can do more than simply modify text—they can expose UI toggles and display custom icons. For instance, you might want a filter that can be turned on/off with a user interface button, and displays a special icon in Open WebUI’s message input UI.
|
||||
|
||||
Here’s how you could create such a toggle filter:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.toggle = True # IMPORTANT: This creates a switch UI in Open WebUI
|
||||
# TIP: Use SVG Data URI!
|
||||
self.icon = """data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZT0iY3VycmVudENvbG9yIiBjbGFzcz0ic2l6ZS02Ij4KICA8cGF0aCBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xMiAxOHYtNS4yNW0wIDBhNi4wMSA2LjAxIDAgMCAwIDEuNS0uMTg5bS0xLjUuMTg5YTYuMDEgNi4wMSAwIDAgMS0xLjUtLjE4OW0zLjc1IDcuNDc4YTEyLjA2IDEyLjA2IDAgMCAxLTQuNSAwbTMuNzUgMi4zODNhMTQuNDA2IDE0LjQwNiAwIDAgMS0zIDBNMTQuMjUgMTh2LS4xOTJjMC0uOTgzLjY1OC0xLjgyMyAxLjUwOC0yLjMxNmE3LjUgNy41IDAgMSAwLTcuNTE3IDBjLjg1LjQ5MyAxLjUwOSAxLjMzMyAxLjUwOSAyLjMxNlYxOCIgLz4KPC9zdmc+Cg=="""
|
||||
pass
|
||||
|
||||
async def inlet(
|
||||
self, body: dict, __event_emitter__, __user__: Optional[dict] = None
|
||||
) -> dict:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Toggled!",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
```
|
||||
|
||||
#### 🖼️ What’s happening?
|
||||
- **toggle = True** creates a switch UI in Open WebUI—users can manually enable or disable the filter in real time.
|
||||
- **icon** (with a Data URI) will show up as a little image next to the filter’s name. You can use any SVG as long as it’s Data URI encoded!
|
||||
- **The `inlet` function** uses the `__event_emitter__` special argument to broadcast feedback/status to the UI, such as a little toast/notification that reads "Toggled!"
|
||||
|
||||

|
||||
|
||||
You can use these mechanisms to make your filters dynamic, interactive, and visually unique within Open WebUI’s plugin ecosystem.
|
||||
|
||||
---
|
||||
|
||||
### 🎯 Key Components Explained
|
||||
|
||||
#### 1️⃣ **`Valves` Class (Optional Settings)**
|
||||
|
||||
Think of **Valves** as the knobs and sliders for your filter. If you want to give users configurable options to adjust your Filter’s behavior, you define those here.
|
||||
|
||||
```python
|
||||
class Valves(BaseModel):
|
||||
OPTION_NAME: str = "Default Value"
|
||||
```
|
||||
|
||||
For example:
|
||||
If you're creating a filter that converts responses into uppercase, you might allow users to configure whether every output gets totally capitalized via a valve like `TRANSFORM_UPPERCASE: bool = True/False`.
|
||||
|
||||
##### Configuring Valves with Dropdown Menus (Enums)
|
||||
|
||||
You can enhance the user experience for your filter's settings by providing dropdown menus instead of free-form text inputs for certain `Valves`. This is achieved using `json_schema_extra` with the `enum` keyword in your Pydantic `Field` definitions.
|
||||
|
||||
The `enum` keyword allows you to specify a list of predefined values that the UI should present as options in a dropdown.
|
||||
|
||||
**Example:** Creating a dropdown for color themes in a filter.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
# Define your available options (e.g., color themes)
|
||||
COLOR_THEMES = {
|
||||
"Plain (No Color)": [],
|
||||
"Monochromatic Blue": ["blue", "RoyalBlue", "SteelBlue", "LightSteelBlue"],
|
||||
"Warm & Energetic": ["orange", "red", "magenta", "DarkOrange"],
|
||||
"Cool & Calm": ["cyan", "blue", "green", "Teal", "CadetBlue"],
|
||||
"Forest & Earth": ["green", "DarkGreen", "LimeGreen", "OliveGreen"],
|
||||
"Mystical Purple": ["purple", "DarkOrchid", "MediumPurple", "Lavender"],
|
||||
"Grayscale": ["gray", "DarkGray", "LightGray"],
|
||||
"Rainbow Fun": [
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"green",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
],
|
||||
"Ocean Breeze": ["blue", "cyan", "LightCyan", "DarkTurquoise"],
|
||||
"Sunset Glow": ["DarkRed", "DarkOrange", "Orange", "gold"],
|
||||
"Custom Sequence (See Code)": [],
|
||||
}
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
selected_theme: str = Field(
|
||||
"Monochromatic Blue",
|
||||
description="Choose a predefined color theme for LLM responses. 'Plain (No Color)' disables coloring.",
|
||||
json_schema_extra={"enum": list(COLOR_THEMES.keys())}, # KEY: This creates the dropdown
|
||||
)
|
||||
custom_colors_csv: str = Field(
|
||||
"",
|
||||
description="CSV of colors for 'Custom Sequence' theme (e.g., 'red,blue,green'). Uses xcolor names.",
|
||||
)
|
||||
strip_existing_latex: bool = Field(
|
||||
True,
|
||||
description="If true, attempts to remove existing LaTeX color commands. Recommended to avoid nested rendering issues.",
|
||||
)
|
||||
colorize_type: str = Field(
|
||||
"sequential_word",
|
||||
description="How to apply colors: 'sequential_word' (word by word), 'sequential_line' (line by line), 'per_letter' (letter by letter), 'full_message' (entire message).",
|
||||
json_schema_extra={
|
||||
"enum": [
|
||||
"sequential_word",
|
||||
"sequential_line",
|
||||
"per_letter",
|
||||
"full_message",
|
||||
]
|
||||
}, # Another example of an enum dropdown
|
||||
)
|
||||
color_cycle_reset_per_message: bool = Field(
|
||||
True,
|
||||
description="If true, the color sequence restarts for each new LLM response message. If false, it continues across messages.",
|
||||
)
|
||||
debug_logging: bool = Field(
|
||||
False,
|
||||
description="Enable verbose logging to the console for debugging filter operations.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
# ... rest of your __init__ logic ...
|
||||
```
|
||||
|
||||
**What's happening?**
|
||||
|
||||
* **`json_schema_extra`**: This argument in `Field` allows you to inject arbitrary JSON Schema properties that Pydantic doesn't explicitly support but can be used by downstream tools (like Open WebUI's UI renderer).
|
||||
* **`"enum": list(COLOR_THEMES.keys())`**: This tells Open WebUI that the `selected_theme` field should present a selection of values, specifically the keys from our `COLOR_THEMES` dictionary. The UI will then render a dropdown menu with "Plain (No Color)", "Monochromatic Blue", "Warm & Energetic", etc., as selectable options.
|
||||
* The `colorize_type` field also demonstrates another `enum` dropdown for different coloring methods.
|
||||
|
||||
Using `enum` for your `Valves` options makes your filters more user-friendly and prevents invalid inputs, leading to a smoother configuration experience.
|
||||
|
||||
---
|
||||
|
||||
#### 2️⃣ **`inlet` Function (Input Pre-Processing)**
|
||||
|
||||
The `inlet` function is like **prepping food before cooking**. Imagine you’re a chef: before the ingredients go into the recipe (the LLM in this case), you might wash vegetables, chop onions, or season the meat. Without this step, your final dish could lack flavor, have unwashed produce, or simply be inconsistent.
|
||||
|
||||
In the world of Open WebUI, the `inlet` function does this important prep work on the **user input** before it’s sent to the model. It ensures the input is as clean, contextual, and helpful as possible for the AI to handle.
|
||||
|
||||
📥 **Input**:
|
||||
- **`body`**: The raw input from Open WebUI to the model. It is in the format of a chat-completion request (usually a dictionary that includes fields like the conversation's messages, model settings, and other metadata). Think of this as your recipe ingredients.
|
||||
|
||||
🚀 **Your Task**:
|
||||
Modify and return the `body`. The modified version of the `body` is what the LLM works with, so this is your chance to bring clarity, structure, and context to the input.
|
||||
|
||||
##### 🍳 Why Would You Use the `inlet`?
|
||||
1. **Adding Context**: Automatically append crucial information to the user’s input, especially if their text is vague or incomplete. For example, you might add "You are a friendly assistant" or "Help this user troubleshoot a software bug."
|
||||
|
||||
2. **Formatting Data**: If the input requires a specific format, like JSON or Markdown, you can transform it before sending it to the model.
|
||||
|
||||
3. **Sanitizing Input**: Remove unwanted characters, strip potentially harmful or confusing symbols (like excessive whitespace or emojis), or replace sensitive information.
|
||||
|
||||
4. **Streamlining User Input**: If your model’s output improves with additional guidance, you can use the `inlet` to inject clarifying instructions automatically!
|
||||
|
||||
##### 💡 Example Use Cases: Build on Food Prep
|
||||
|
||||
###### 🥗 Example 1: Adding System Context
|
||||
Let’s say the LLM is a chef preparing a dish for Italian cuisine, but the user hasn’t mentioned "This is for Italian cooking." You can ensure the message is clear by appending this context before sending the data to the model.
|
||||
|
||||
```python
|
||||
def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
# Add system message for Italian context in the conversation
|
||||
context_message = {
|
||||
"role": "system",
|
||||
"content": "You are helping the user prepare an Italian meal."
|
||||
}
|
||||
# Insert the context at the beginning of the chat history
|
||||
body.setdefault("messages", []).insert(0, context_message)
|
||||
return body
|
||||
```
|
||||
|
||||
📖 **What Happens?**
|
||||
- Any user input like "What are some good dinner ideas?" now carries the Italian theme because we’ve set the system context! Cheesecake might not show up as an answer, but pasta sure will.
|
||||
|
||||
###### 🔪 Example 2: Cleaning Input (Remove Odd Characters)
|
||||
Suppose the input from the user looks messy or includes unwanted symbols like `!!!`, making the conversation inefficient or harder for the model to parse. You can clean it up while preserving the core content.
|
||||
|
||||
```python
|
||||
def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
# Clean the last user input (from the end of the 'messages' list)
|
||||
last_message = body["messages"][-1]["content"]
|
||||
body["messages"][-1]["content"] = last_message.replace("!!!", "").strip()
|
||||
return body
|
||||
```
|
||||
|
||||
📖 **What Happens?**
|
||||
- Before: `"How can I debug this issue!!!"` ➡️ Sent to the model as `"How can I debug this issue"`
|
||||
|
||||
:::note
|
||||
|
||||
Note: The user feels the same, but the model processes a cleaner and easier-to-understand query.
|
||||
|
||||
:::
|
||||
|
||||
##### 📊 How `inlet` Helps Optimize Input for the LLM:
|
||||
- Improves **accuracy** by clarifying ambiguous queries.
|
||||
- Makes the AI **more efficient** by removing unnecessary noise like emojis, HTML tags, or extra punctuation.
|
||||
- Ensures **consistency** by formatting user input to match the model’s expected patterns or schemas (like, say, JSON for a specific use case).
|
||||
|
||||
💭 **Think of `inlet` as the sous-chef in your kitchen**—ensuring everything that goes into the model (your AI "recipe") has been prepped, cleaned, and seasoned to perfection. The better the input, the better the output!
|
||||
|
||||
---
|
||||
|
||||
#### 🆕 3️⃣ **`stream` Hook (New in Open WebUI 0.5.17)**
|
||||
|
||||
##### 🔄 What is the `stream` Hook?
|
||||
The **`stream` function** is a new feature introduced in Open WebUI **0.5.17** that allows you to **intercept and modify streamed model responses** in real time.
|
||||
|
||||
Unlike `outlet`, which processes an entire completed response, `stream` operates on **individual chunks** as they are received from the model.
|
||||
|
||||
##### 🛠️ When to Use the Stream Hook?
|
||||
- Modify **streaming responses** before they are displayed to users.
|
||||
- Implement **real-time censorship or cleanup**.
|
||||
- **Monitor streamed data** for logging/debugging.
|
||||
|
||||
##### 📜 Example: Logging Streaming Chunks
|
||||
|
||||
Here’s how you can inspect and modify streamed LLM responses:
|
||||
```python
|
||||
def stream(self, event: dict) -> dict:
|
||||
print(event) # Print each incoming chunk for inspection
|
||||
return event
|
||||
```
|
||||
|
||||
> **Example Streamed Events:**
|
||||
```jsonl
|
||||
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": "Hi"}}]}
|
||||
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": "!"}}]}
|
||||
{"id": "chatcmpl-B4l99MMaP3QLGU5uV7BaBM0eDS0jb","choices": [{"delta": {"content": " 😊"}}]}
|
||||
```
|
||||
📖 **What Happens?**
|
||||
- Each line represents a **small fragment** of the model's streamed response.
|
||||
- The **`delta.content` field** contains the progressively generated text.
|
||||
|
||||
##### 🔄 Example: Filtering Out Emojis from Streamed Data
|
||||
```python
|
||||
def stream(self, event: dict) -> dict:
|
||||
for choice in event.get("choices", []):
|
||||
delta = choice.get("delta", {})
|
||||
if "content" in delta:
|
||||
delta["content"] = delta["content"].replace("😊", "") # Strip emojis
|
||||
return event
|
||||
```
|
||||
📖 **Before:** `"Hi 😊"`
|
||||
📖 **After:** `"Hi"`
|
||||
|
||||
---
|
||||
|
||||
#### 4️⃣ **`outlet` Function (Output Post-Processing)**
|
||||
|
||||
The `outlet` function is like a **proofreader**: tidy up the AI's response (or make final changes) *after it’s processed by the LLM.*
|
||||
|
||||
📤 **Input**:
|
||||
- **`body`**: This contains **all current messages** in the chat (user history + LLM replies).
|
||||
|
||||
🚀 **Your Task**: Modify this `body`. You can clean, append, or log changes, but be mindful of how each adjustment impacts the user experience.
|
||||
|
||||
💡 **Best Practices**:
|
||||
- Prefer logging over direct edits in the outlet (e.g., for debugging or analytics).
|
||||
- If heavy modifications are needed (like formatting outputs), consider using the **pipe function** instead.
|
||||
|
||||
💡 **Example Use Case**: Strip out sensitive API responses you don't want the user to see:
|
||||
```python
|
||||
def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
for message in body["messages"]:
|
||||
message["content"] = message["content"].replace("<API_KEY>", "[REDACTED]")
|
||||
return body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Filters in Action: Building Practical Examples
|
||||
|
||||
Let’s build some real-world examples to see how you’d use Filters!
|
||||
|
||||
### 📚 Example #1: Add Context to Every User Input
|
||||
|
||||
Want the LLM to always know it's assisting a customer in troubleshooting software bugs? You can add instructions like **"You're a software troubleshooting assistant"** to every user query.
|
||||
|
||||
```python
|
||||
class Filter:
|
||||
def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
context_message = {
|
||||
"role": "system",
|
||||
"content": "You're a software troubleshooting assistant."
|
||||
}
|
||||
body.setdefault("messages", []).insert(0, context_message)
|
||||
return body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📚 Example #2: Highlight Outputs for Easy Reading
|
||||
|
||||
Returning output in Markdown or another formatted style? Use the `outlet` function!
|
||||
|
||||
```python
|
||||
class Filter:
|
||||
def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
|
||||
# Add "highlight" markdown for every response
|
||||
for message in body["messages"]:
|
||||
if message["role"] == "assistant": # Target model response
|
||||
message["content"] = f"**{message['content']}**" # Highlight with Markdown
|
||||
return body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Potential Confusion: Clear FAQ 🛑
|
||||
|
||||
### **Q: How Are Filters Different From Pipe Functions?**
|
||||
|
||||
Filters modify data **going to** and **coming from models** but do not significantly interact with logic outside of these phases. Pipes, on the other hand:
|
||||
- Can integrate **external APIs** or significantly transform how the backend handles operations.
|
||||
- Expose custom logic as entirely new "models."
|
||||
|
||||
### **Q: Can I Do Heavy Post-Processing Inside `outlet`?**
|
||||
|
||||
You can, but **it’s not the best practice.**:
|
||||
- **Filters** are designed to make lightweight changes or apply logging.
|
||||
- If heavy modifications are required, consider a **Pipe Function** instead.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Recap: Why Build Filter Functions?
|
||||
|
||||
By now, you’ve learned:
|
||||
1. **Inlet** manipulates **user inputs** (pre-processing).
|
||||
2. **Stream** intercepts and modifies **streamed model outputs** (real-time).
|
||||
3. **Outlet** tweaks **AI outputs** (post-processing).
|
||||
4. Filters are best for lightweight, real-time alterations to the data flow.
|
||||
5. With **Valves**, you empower users to configure Filters dynamically for tailored behavior.
|
||||
|
||||
---
|
||||
|
||||
🚀 **Your Turn**: Start experimenting! What small tweak or context addition could elevate your Open WebUI experience? Filters are fun to build, flexible to use, and can take your models to the next level!
|
||||
|
||||
Happy coding! ✨
|
||||
133
docs/features/plugin/functions/index.mdx
Normal file
133
docs/features/plugin/functions/index.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: "Functions"
|
||||
---
|
||||
|
||||
## 🚀 What Are Functions?
|
||||
|
||||
Functions are like **plugins** for Open WebUI. They help you **extend its capabilities**—whether it’s adding support for new AI model providers like Anthropic or Vertex AI, tweaking how messages are processed, or introducing custom buttons to the interface for better usability.
|
||||
|
||||
Unlike external tools that may require complex integrations, **Functions are built-in and run within the Open WebUI environment.** That means they are fast, modular, and don’t rely on external dependencies.
|
||||
|
||||
Think of Functions as **modular building blocks** that let you enhance how the WebUI works, tailored exactly to what you need. They’re lightweight, highly customizable, and written in **pure Python**, so you have the freedom to create anything—from new AI-powered workflows to integrations with anything you use, like Google Search or Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Types of Functions
|
||||
|
||||
There are **three types of Functions** in Open WebUI, each with a specific purpose. Let’s break them down and explain exactly what they do:
|
||||
|
||||
---
|
||||
|
||||
### 1. [**Pipe Function** – Create Custom "Agents/Models"](./pipe.mdx)
|
||||
|
||||
A **Pipe Function** is how you create **custom agents/models** or integrations, which then appear in the interface as if they were standalone models.
|
||||
|
||||
**What does it do?**
|
||||
- Pipes let you define complex workflows. For instance, you could create a Pipe that sends data to **Model A** and **Model B**, processes their outputs, and combines the results into one finalized answer.
|
||||
- Pipes don’t even have to use AI! They can be setups for **search APIs**, **weather data**, or even systems like **Home Assistant**. Basically, anything you’d like to interact with can become part of Open WebUI.
|
||||
|
||||
**Use case example:**
|
||||
Imagine you want to query Google Search directly from Open WebUI. You can create a Pipe Function that:
|
||||
1. Takes your message as the search query.
|
||||
2. Sends the query to Google Search’s API.
|
||||
3. Processes the response and returns it to you inside the WebUI like a normal "model" response.
|
||||
|
||||
When enabled, **Pipe Functions show up as their own selectable model**. Use Pipes whenever you need custom functionality that works like a model in the interface.
|
||||
|
||||
For a detailed guide, see [**Pipe Functions**](./pipe.mdx).
|
||||
|
||||
---
|
||||
|
||||
### 2. [**Filter Function** – Modify Inputs and Outputs](./filter.mdx)
|
||||
|
||||
A **Filter Function** is like a tool for tweaking data before it gets sent to the AI **or** after it comes back.
|
||||
|
||||
**What does it do?**
|
||||
Filters act as "hooks" in the workflow and have two main parts:
|
||||
- **Inlet**: Adjust the input that is sent to the model. For example, adding additional instructions, keywords, or formatting tweaks.
|
||||
- **Outlet**: Modify the output that you receive from the model. For instance, cleaning up the response, adjusting tone, or formatting data into a specific style.
|
||||
|
||||
**Use case example:**
|
||||
Suppose you’re working on a project that needs precise formatting. You can use a Filter to ensure:
|
||||
1. Your input is always transformed into the required format.
|
||||
2. The output from the model is cleaned up before being displayed.
|
||||
|
||||
Filters are **linked to specific models** or can be enabled for all models **globally**, depending on your needs.
|
||||
|
||||
Check out the full guide for more examples and instructions: [**Filter Functions**](./filter.mdx).
|
||||
|
||||
---
|
||||
|
||||
### 3. [**Action Function** – Add Custom Buttons](./action.mdx)
|
||||
|
||||
An **Action Function** is used to add **custom buttons** to the chat interface.
|
||||
|
||||
**What does it do?**
|
||||
Actions allow you to define **interactive shortcuts** that trigger specific functionality directly from the chat. These buttons appear underneath individual chat messages, giving you convenient, one-click access to the actions you define.
|
||||
|
||||
**Use case example:**
|
||||
Let’s say you often need to summarize long messages or generate specific outputs like translations. You can create an Action Function to:
|
||||
1. Add a “Summarize” button under every incoming message.
|
||||
2. When clicked, it triggers your custom function to process that message and return the summary.
|
||||
|
||||
Buttons provide a **clean and user-friendly way** to interact with extended functionality you define.
|
||||
|
||||
Learn how to set them up in the [**Action Functions Guide**](./action.mdx).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ How to Use Functions
|
||||
|
||||
Here's how to put Functions to work in Open WebUI:
|
||||
|
||||
### 1. **Install Functions**
|
||||
You can install Functions via the Open WebUI interface or by importing them manually. You can find community-created functions on the [Open WebUI Community Site](https://openwebui.com/functions).
|
||||
|
||||
⚠️ **Be cautious.** Only install Functions from trusted sources. Running unknown code poses security risks.
|
||||
|
||||
---
|
||||
|
||||
### 2. **Enable Functions**
|
||||
Functions must be explicitly enabled after installation:
|
||||
- When you enable a **Pipe Function**, it becomes available as its own **model** in the interface.
|
||||
- For **Filter** and **Action Functions**, enabling them isn’t enough—you also need to assign them to specific models or enable them globally for all models.
|
||||
|
||||
---
|
||||
|
||||
### 3. **Assign Filters or Actions to Models**
|
||||
- Navigate to `Workspace => Models` and assign your Filter or Action to the relevant model there.
|
||||
- Alternatively, enable Functions for **all models globally** by going to `Workspace => Functions`, selecting the "..." menu, and toggling the **Global** switch.
|
||||
|
||||
---
|
||||
|
||||
### Quick Summary
|
||||
- **Pipes** appear as standalone models you can interact with.
|
||||
- **Filters** modify inputs/outputs for smoother AI interactions.
|
||||
- **Actions** add clickable buttons to individual chat messages.
|
||||
|
||||
Once you’ve followed the setup process, Functions will seamlessly enhance your workflows.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Why Use Functions?
|
||||
|
||||
Functions are designed for anyone who wants to **unlock new possibilities** with Open WebUI:
|
||||
|
||||
- **Extend**: Add new models or integrate with non-AI tools like APIs, databases, or smart devices.
|
||||
- **Optimize**: Tweak inputs and outputs to fit your use case perfectly.
|
||||
- **Simplify**: Add buttons or shortcuts to make the interface intuitive and efficient.
|
||||
|
||||
Whether you’re customizing workflows for specific projects, integrating external data, or just making Open WebUI easier to use, Functions are the key to taking control of your instance.
|
||||
|
||||
---
|
||||
|
||||
### 📝 Final Notes:
|
||||
1. Always install Functions from **trusted sources only**.
|
||||
2. Make sure you understand the difference between Pipe, Filter, and Action Functions to use them effectively.
|
||||
3. Explore the official guides:
|
||||
- [Pipe Functions Guide](./pipe.mdx)
|
||||
- [Filter Functions Guide](./filter.mdx)
|
||||
- [Action Functions Guide](./action.mdx)
|
||||
|
||||
By leveraging Functions, you’ll bring entirely new capabilities to your Open WebUI setup. Start experimenting today! 🚀
|
||||
400
docs/features/plugin/functions/pipe.mdx
Normal file
400
docs/features/plugin/functions/pipe.mdx
Normal file
@@ -0,0 +1,400 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: "Pipe Function"
|
||||
---
|
||||
|
||||
# 🚰 Pipe Function: Create Custom "Agents/Models"
|
||||
Welcome to this guide on creating **Pipes** in Open WebUI! Think of Pipes as a way to **adding** a new model to Open WebUI. In this document, we'll break down what a Pipe is, how it works, and how you can create your own to add custom logic and processing to your Open WebUI models. We'll use clear metaphors and go through every detail to ensure you have a comprehensive understanding.
|
||||
|
||||
## Introduction to Pipes
|
||||
|
||||
Imagine Open WebUI as a **plumbing system** where data flows through pipes and valves. In this analogy:
|
||||
|
||||
- **Pipes** are like **plugins** that let you introduce new pathways for data to flow, allowing you to inject custom logic and processing.
|
||||
- **Valves** are the **configurable parts** of your pipe that control how data flows through it.
|
||||
|
||||
By creating a Pipe, you're essentially crafting a custom model with the specific behavior you want, all within the Open WebUI framework.
|
||||
|
||||
---
|
||||
|
||||
## Understanding the Pipe Structure
|
||||
|
||||
Let's start with a basic, barebones version of a Pipe to understand its structure:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Pipe:
|
||||
class Valves(BaseModel):
|
||||
MODEL_ID: str = Field(default="")
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def pipe(self, body: dict):
|
||||
# Logic goes here
|
||||
print(self.valves, body) # This will print the configuration options and the input body
|
||||
return "Hello, World!"
|
||||
```
|
||||
|
||||
### The Pipe Class
|
||||
|
||||
- **Definition**: The `Pipe` class is where you define your custom logic.
|
||||
- **Purpose**: Acts as the blueprint for your plugin, determining how it behaves within Open WebUI.
|
||||
|
||||
### Valves: Configuring Your Pipe
|
||||
|
||||
- **Definition**: `Valves` is a nested class within `Pipe`, inheriting from `BaseModel`.
|
||||
- **Purpose**: It contains the configuration options (parameters) that persist across the use of your Pipe.
|
||||
- **Example**: In the above code, `MODEL_ID` is a configuration option with a default empty string.
|
||||
|
||||
**Metaphor**: Think of Valves as the knobs on a real-world pipe system that control the flow of water. In your Pipe, Valves allow users to adjust settings that influence how the data flows and is processed.
|
||||
|
||||
### The `__init__` Method
|
||||
|
||||
- **Definition**: The constructor method for the `Pipe` class.
|
||||
- **Purpose**: Initializes the Pipe's state and sets up any necessary components.
|
||||
- **Best Practice**: Keep it simple; primarily initialize `self.valves` here.
|
||||
|
||||
```python
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
```
|
||||
|
||||
### The `pipe` Function
|
||||
|
||||
- **Definition**: The core function where your custom logic resides.
|
||||
- **Parameters**:
|
||||
- `body`: A dictionary containing the input data.
|
||||
- **Purpose**: Processes the input data using your custom logic and returns the result.
|
||||
|
||||
```python
|
||||
def pipe(self, body: dict):
|
||||
# Logic goes here
|
||||
print(self.valves, body) # This will print the configuration options and the input body
|
||||
return "Hello, World!"
|
||||
```
|
||||
|
||||
**Note**: Always place `Valves` at the top of your `Pipe` class, followed by `__init__`, and then the `pipe` function. This structure ensures clarity and consistency.
|
||||
|
||||
---
|
||||
|
||||
## Creating Multiple Models with Pipes
|
||||
|
||||
What if you want your Pipe to create **multiple models** within Open WebUI? You can achieve this by defining a `pipes` function or variable inside your `Pipe` class. This setup, informally called a **manifold**, allows your Pipe to represent multiple models.
|
||||
|
||||
Here's how you can do it:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Pipe:
|
||||
class Valves(BaseModel):
|
||||
MODEL_ID: str = Field(default="")
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def pipes(self):
|
||||
return [
|
||||
{"id": "model_id_1", "name": "model_1"},
|
||||
{"id": "model_id_2", "name": "model_2"},
|
||||
{"id": "model_id_3", "name": "model_3"},
|
||||
]
|
||||
|
||||
def pipe(self, body: dict):
|
||||
# Logic goes here
|
||||
print(self.valves, body) # Prints the configuration options and the input body
|
||||
model = body.get("model", "")
|
||||
return f"{model}: Hello, World!"
|
||||
```
|
||||
|
||||
### Explanation
|
||||
|
||||
- **`pipes` Function**:
|
||||
- Returns a list of dictionaries.
|
||||
- Each dictionary represents a model with unique `id` and `name` keys.
|
||||
- These models will show up individually in the Open WebUI model selector.
|
||||
|
||||
- **Updated `pipe` Function**:
|
||||
- Processes input based on the selected model.
|
||||
- In this example, it includes the model name in the returned string.
|
||||
|
||||
---
|
||||
|
||||
## Example: OpenAI Proxy Pipe
|
||||
|
||||
Let's dive into a practical example where we'll create a Pipe that proxies requests to the OpenAI API. This Pipe will fetch available models from OpenAI and allow users to interact with them through Open WebUI.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
import requests
|
||||
|
||||
class Pipe:
|
||||
class Valves(BaseModel):
|
||||
NAME_PREFIX: str = Field(
|
||||
default="OPENAI/",
|
||||
description="Prefix to be added before model names.",
|
||||
)
|
||||
OPENAI_API_BASE_URL: str = Field(
|
||||
default="https://api.openai.com/v1",
|
||||
description="Base URL for accessing OpenAI API endpoints.",
|
||||
)
|
||||
OPENAI_API_KEY: str = Field(
|
||||
default="",
|
||||
description="API key for authenticating requests to the OpenAI API.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def pipes(self):
|
||||
if self.valves.OPENAI_API_KEY:
|
||||
try:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.valves.OPENAI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
r = requests.get(
|
||||
f"{self.valves.OPENAI_API_BASE_URL}/models", headers=headers
|
||||
)
|
||||
models = r.json()
|
||||
return [
|
||||
{
|
||||
"id": model["id"],
|
||||
"name": f'{self.valves.NAME_PREFIX}{model.get("name", model["id"])}',
|
||||
}
|
||||
for model in models["data"]
|
||||
if "gpt" in model["id"]
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
return [
|
||||
{
|
||||
"id": "error",
|
||||
"name": "Error fetching models. Please check your API Key.",
|
||||
},
|
||||
]
|
||||
else:
|
||||
return [
|
||||
{
|
||||
"id": "error",
|
||||
"name": "API Key not provided.",
|
||||
},
|
||||
]
|
||||
|
||||
def pipe(self, body: dict, __user__: dict):
|
||||
print(f"pipe:{__name__}")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.valves.OPENAI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Extract model id from the model name
|
||||
model_id = body["model"][body["model"].find(".") + 1 :]
|
||||
|
||||
# Update the model id in the body
|
||||
payload = {**body, "model": model_id}
|
||||
try:
|
||||
r = requests.post(
|
||||
url=f"{self.valves.OPENAI_API_BASE_URL}/chat/completions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
if body.get("stream", False):
|
||||
return r.iter_lines()
|
||||
else:
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
```
|
||||
|
||||
### Detailed Breakdown
|
||||
|
||||
#### Valves Configuration
|
||||
|
||||
- **`NAME_PREFIX`**:
|
||||
- Adds a prefix to the model names displayed in Open WebUI.
|
||||
- Default: `"OPENAI/"`.
|
||||
- **`OPENAI_API_BASE_URL`**:
|
||||
- Specifies the base URL for the OpenAI API.
|
||||
- Default: `"https://api.openai.com/v1"`.
|
||||
- **`OPENAI_API_KEY`**:
|
||||
- Your OpenAI API key for authentication.
|
||||
- Default: `""` (empty string; must be provided).
|
||||
|
||||
#### The `pipes` Function
|
||||
|
||||
- **Purpose**: Fetches available OpenAI models and makes them accessible in Open WebUI.
|
||||
|
||||
- **Process**:
|
||||
1. **Check for API Key**: Ensures that an API key is provided.
|
||||
2. **Fetch Models**: Makes a GET request to the OpenAI API to retrieve available models.
|
||||
3. **Filter Models**: Returns models that have `"gpt"` in their `id`.
|
||||
4. **Error Handling**: If there's an issue, returns an error message.
|
||||
|
||||
- **Return Format**: A list of dictionaries with `id` and `name` for each model.
|
||||
|
||||
#### The `pipe` Function
|
||||
|
||||
- **Purpose**: Handles the request to the selected OpenAI model and returns the response.
|
||||
|
||||
- **Parameters**:
|
||||
- `body`: Contains the request data.
|
||||
- `__user__`: Contains user information (not used in this example but can be useful for authentication or logging).
|
||||
|
||||
- **Process**:
|
||||
1. **Prepare Headers**: Sets up the headers with the API key and content type.
|
||||
2. **Extract Model ID**: Extracts the actual model ID from the selected model name.
|
||||
3. **Prepare Payload**: Updates the body with the correct model ID.
|
||||
4. **Make API Request**: Sends a POST request to the OpenAI API's chat completions endpoint.
|
||||
5. **Handle Streaming**: If `stream` is `True`, returns an iterable of lines.
|
||||
6. **Error Handling**: Catches exceptions and returns an error message.
|
||||
|
||||
### Extending the Proxy Pipe
|
||||
|
||||
You can modify this proxy Pipe to support additional service providers like Anthropic, Perplexity, and more by adjusting the API endpoints, headers, and logic within the `pipes` and `pipe` functions.
|
||||
|
||||
---
|
||||
|
||||
## Using Internal Open WebUI Functions
|
||||
|
||||
Sometimes, you may want to leverage the internal functions of Open WebUI within your Pipe. You can import these functions directly from the `open_webui` package. Keep in mind that while unlikely, internal functions may change for optimization purposes, so always refer to the latest documentation.
|
||||
|
||||
Here's how you can use internal Open WebUI functions:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
class Pipe:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def pipe(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: dict,
|
||||
__request__: Request,
|
||||
) -> str:
|
||||
# Use the unified endpoint with the updated signature
|
||||
user = Users.get_user_by_id(__user__["id"])
|
||||
body["model"] = "llama3.2:latest"
|
||||
return await generate_chat_completion(__request__, body, user)
|
||||
```
|
||||
|
||||
### Explanation
|
||||
|
||||
- **Imports**:
|
||||
- `Users` from `open_webui.models.users`: To fetch user information.
|
||||
- `generate_chat_completion` from `open_webui.utils.chat`: To generate chat completions using internal logic.
|
||||
|
||||
- **Asynchronous `pipe` Function**:
|
||||
- **Parameters**:
|
||||
- `body`: Input data for the model.
|
||||
- `__user__`: Dictionary containing user information.
|
||||
- `__request__`: The request object from FastAPI (required by `generate_chat_completion`).
|
||||
- **Process**:
|
||||
1. **Fetch User Object**: Retrieves the user object using their ID.
|
||||
2. **Set Model**: Specifies the model to be used.
|
||||
3. **Generate Completion**: Calls `generate_chat_completion` to process the input and produce an output.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Function Signatures**: Refer to the latest Open WebUI codebase or documentation for the most accurate function signatures and parameters.
|
||||
- **Best Practices**: Always handle exceptions and errors gracefully to ensure a smooth user experience.
|
||||
|
||||
---
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Q1: Why should I use Pipes in Open WebUI?
|
||||
|
||||
**A**: Pipes allow you to add new "model" with custom logic and processing to Open WebUI. It's a flexible plugin system that lets you integrate external APIs, customize model behaviors, and create innovative features without altering the core codebase.
|
||||
|
||||
---
|
||||
|
||||
### Q2: What are Valves, and why are they important?
|
||||
|
||||
**A**: Valves are the configurable parameters of your Pipe. They function like settings or controls that determine how your Pipe operates. By adjusting Valves, you can change the behavior of your Pipe without modifying the underlying code.
|
||||
|
||||
---
|
||||
|
||||
### Q3: Can I create a Pipe without Valves?
|
||||
|
||||
**A**: Yes, you can create a simple Pipe without defining a Valves class if your Pipe doesn't require any persistent configuration options. However, including Valves is a good practice for flexibility and future scalability.
|
||||
|
||||
---
|
||||
|
||||
### Q4: How do I ensure my Pipe is secure when using API keys?
|
||||
|
||||
**A**: Never hard-code sensitive information like API keys into your Pipe. Instead, use Valves to input and store API keys securely. Ensure that your code handles these keys appropriately and avoids logging or exposing them.
|
||||
|
||||
---
|
||||
|
||||
### Q5: What is the difference between the `pipe` and `pipes` functions?
|
||||
|
||||
**A**:
|
||||
|
||||
- **`pipe` Function**: The primary function where you process the input data and generate an output. It handles the logic for a single model.
|
||||
|
||||
- **`pipes` Function**: Allows your Pipe to represent multiple models by returning a list of model definitions. Each model will appear individually in Open WebUI.
|
||||
|
||||
---
|
||||
|
||||
### Q6: How can I handle errors in my Pipe?
|
||||
|
||||
**A**: Use try-except blocks within your `pipe` and `pipes` functions to catch exceptions. Return meaningful error messages or handle the errors gracefully to ensure the user is informed about what went wrong.
|
||||
|
||||
---
|
||||
|
||||
### Q7: Can I use external libraries in my Pipe?
|
||||
|
||||
**A**: Yes, you can import and use external libraries as needed. Ensure that any dependencies are properly installed and managed within your environment.
|
||||
|
||||
---
|
||||
|
||||
### Q8: How do I test my Pipe?
|
||||
|
||||
**A**: Test your Pipe by running Open WebUI in a development environment and selecting your custom model from the interface. Validate that your Pipe behaves as expected with various inputs and configurations.
|
||||
|
||||
---
|
||||
|
||||
### Q9: Are there any best practices for organizing my Pipe's code?
|
||||
|
||||
**A**: Yes, follow these guidelines:
|
||||
|
||||
- Keep `Valves` at the top of your `Pipe` class.
|
||||
- Initialize variables in the `__init__` method, primarily `self.valves`.
|
||||
- Place the `pipe` function after the `__init__` method.
|
||||
- Use clear and descriptive variable names.
|
||||
- Comment your code for clarity.
|
||||
|
||||
---
|
||||
|
||||
### Q10: Where can I find the latest Open WebUI documentation?
|
||||
|
||||
**A**: Visit the official Open WebUI repository or documentation site for the most up-to-date information, including function signatures, examples, and migration guides if any changes occur.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
By now, you should have a thorough understanding of how to create and use Pipes in Open WebUI. Pipes offer a powerful way to extend and customize the capabilities of Open WebUI to suit your specific needs. Whether you're integrating external APIs, adding new models, or injecting complex logic, Pipes provide the flexibility to make it happen.
|
||||
|
||||
Remember to:
|
||||
|
||||
- **Use clear and consistent structure** in your Pipe classes.
|
||||
- **Leverage Valves** for configurable options.
|
||||
- **Handle errors gracefully** to improve the user experience.
|
||||
- **Consult the latest documentation** for any updates or changes.
|
||||
|
||||
Happy coding, and enjoy extending your Open WebUI with Pipes!
|
||||
91
docs/features/plugin/index.mdx
Normal file
91
docs/features/plugin/index.mdx
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
sidebar_position: 300
|
||||
title: "Tools & Functions (Plugins)"
|
||||
---
|
||||
|
||||
# 🛠️ Tools & Functions
|
||||
|
||||
Imagine you've just stumbled upon Open WebUI, or maybe you're already using it, but you're a bit lost with all the talk about "Tools", "Functions", and "Pipelines". Everything sounds like some mysterious tech jargon, right? No worries! Let's break it down piece by piece, super clearly, step by step. By the end of this, you'll have a solid understanding of what these terms mean, how they work, and why know it's not as complicated as it seems.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Tools** extend the abilities of LLMs, allowing them to collect real-world, real-time data like weather, stock prices, etc.
|
||||
- **Functions** extend the capabilities of the Open WebUI itself, enabling you to add new AI model support (like Anthropic or Vertex AI) or improve usability (like creating custom buttons or filters).
|
||||
- **Pipelines** are more for advanced users who want to transform Open WebUI features into API-compatible workflows—mainly for offloading heavy processing.
|
||||
|
||||
Getting started with Tools and Functions is easy because everything’s already built into the core system! You just **click a button** and **import these features directly from the community**, so there’s no coding or deep technical work required.
|
||||
|
||||
## What are "Tools" and "Functions"?
|
||||
|
||||
Let's start by thinking of **Open WebUI** as a "base" software that can do many tasks related to using Large Language Models (LLMs). But sometimes, you need extra features or abilities that don't come *out of the box*—this is where **tools** and **functions** come into play.
|
||||
|
||||
### Tools
|
||||
|
||||
**Tools** are an exciting feature because they allow LLMs to do more than just process text. They provide **external abilities** that LLMs wouldn't otherwise have on their own.
|
||||
|
||||
#### Example of a Tool:
|
||||
|
||||
Imagine you're chatting with an LLM and you want it to give you the latest weather update or stock prices in real time. Normally, the LLM can't do that because it's just working on pre-trained knowledge. This is where **tools** come in!
|
||||
|
||||
- **Tools are like plugins** that the LLM can use to gather **real-world, real-time data**. So, with a "weather tool" enabled, the model can go out on the internet, gather live weather data, and display it in your conversation.
|
||||
|
||||
Tools are essentially **abilities** you’re giving your AI to help it interact with the outside world. By adding these, the LLM can "grab" useful information or perform specialized tasks based on the context of the conversation.
|
||||
|
||||
#### Examples of Tools (extending LLM’s abilities):
|
||||
|
||||
1. **Real-time weather predictions** 🛰️.
|
||||
2. **Stock price retrievers** 📈.
|
||||
3. **Flight tracking information** ✈️.
|
||||
|
||||
### Functions
|
||||
|
||||
While **tools** are used by the AI during a conversation, **functions** help extend or customize the capabilities of Open WebUI itself. Imagine tools are like adding new ingredients to a dish, and functions are the process you use to control the kitchen! 🚪
|
||||
|
||||
#### Let's break that down:
|
||||
|
||||
- **Functions** give you the ability to tweak or add **features** inside **Open WebUI** itself.
|
||||
- You’re not giving new abilities to the LLM, but instead, you’re extending the **interface, behavior, or logic** of the platform itself!
|
||||
|
||||
For instance, maybe you want to:
|
||||
|
||||
1. Add a new AI model like **Anthropic** to the WebUI.
|
||||
2. Create a custom button in your toolbar that performs a frequently used command.
|
||||
3. Implement a better **filter** function that catches inappropriate or **spammy messages** from the incoming text.
|
||||
|
||||
Without functions, these would all be out of reach. But with this framework in Open WebUI, you can easily extend these features!
|
||||
|
||||
### Where to Find and Manage Functions
|
||||
|
||||
Functions are not located in the same place as Tools.
|
||||
|
||||
- **Tools** are about model access and live in your **Workspace tabs** (where you add models, prompts, and knowledge collections). They can be added by users if granted permissions.
|
||||
- **Functions** are about **platform customization** and are found in the **Admin Panel**.
|
||||
They are configured and managed only by admins who want to extend the platform interface or behavior for all users.
|
||||
|
||||
### Summary of Differences:
|
||||
|
||||
- **Tools** are things that allow LLMs to **do more things** outside their default abilities (such as retrieving live info or performing custom tasks based on external data).
|
||||
- **Functions** help the WebUI itself **do more things**, like adding new AI models or creating smarter ways to filter data.
|
||||
|
||||
Both are designed to be **pluggable**, meaning you can easily import them into your system with just one click from the community! 🎉 You won’t have to spend hours coding or tinkering with them.
|
||||
|
||||
## What are Pipelines?
|
||||
|
||||
And then, we have **Pipelines**… Here’s where things start to sound pretty technical—but don’t despair.
|
||||
|
||||
**Pipelines** are part of an Open WebUI initiative focused on making every piece of the WebUI **inter-operable with OpenAI’s API system**. Essentially, they extend what both **Tools** and **Functions** can already do, but now with even more flexibility. They allow you to turn features into OpenAI API-compatible formats. 🧠
|
||||
|
||||
### But here’s the thing…
|
||||
|
||||
You likely **won't need** pipelines unless you're dealing with super-advanced setups.
|
||||
|
||||
- **Who are pipelines for?** Typically, **experts** or people running more complicated use cases.
|
||||
- **When do you need them?** If you're trying to offload processing from your primary Open WebUI instance to a different machine (so you don’t overload your primary system).
|
||||
|
||||
In most cases, as a beginner or even an intermediate user, you won’t have to worry about pipelines. Just focus on enjoying the benefits that **tools** and **functions** bring to your Open WebUI experience!
|
||||
|
||||
## Want to Try? 🚀
|
||||
|
||||
Jump into Open WebUI, head over to the community section, and try importing a tool like **weather updates** or maybe adding a new feature to the toolbar with a function. Exploring these tools will show you how powerful and flexible Open WebUI can be!
|
||||
|
||||
🌟 There's always more to learn, so stay curious and keep experimenting!
|
||||
255
docs/features/plugin/migration/index.mdx
Normal file
255
docs/features/plugin/migration/index.mdx
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
sidebar_position: 9999
|
||||
title: "Migrating Tools & Functions: 0.4 to 0.5"
|
||||
---
|
||||
|
||||
# 🚚 Migration Guide: Open WebUI 0.4 to 0.5
|
||||
|
||||
Welcome to the Open WebUI 0.5 migration guide! If you're working on existing projects or building new ones, this guide will walk you through the key changes from **version 0.4 to 0.5** and provide an easy-to-follow roadmap for upgrading your Functions. Let's make this transition as smooth as possible! 😊
|
||||
|
||||
---
|
||||
|
||||
## 🧐 What Has Changed and Why?
|
||||
|
||||
With Open WebUI 0.5, we’ve overhauled the architecture to make the project **simpler, more unified, and scalable**. Here's the big picture:
|
||||
|
||||
- **Old Architecture:** 🎯 Previously, Open WebUI was built on a **sub-app architecture** where each app (e.g., `ollama`, `openai`) was a separate FastAPI application. This caused fragmentation and extra complexity when managing apps.
|
||||
- **New Architecture:** 🚀 With version 0.5, we have transitioned to a **single FastAPI app** with multiple **routers**. This means better organization, centralized flow, and reduced redundancy.
|
||||
|
||||
### Key Changes:
|
||||
Here’s an overview of what changed:
|
||||
1. **Apps have been moved to Routers.**
|
||||
- Previous: `open_webui.apps`
|
||||
- Now: `open_webui.routers`
|
||||
|
||||
2. **Main app structure simplified.**
|
||||
- The old `open_webui.apps.webui` has been transformed into `open_webui.main`, making it the central entry point for the project.
|
||||
|
||||
3. **Unified API Endpoint**
|
||||
- Open WebUI 0.5 introduces a **unified function**, `chat_completion`, in `open_webui.main`, replacing separate functions for models like `ollama` and `openai`. This offers a consistent and streamlined API experience. However, the **direct successor** of these individual functions is `generate_chat_completion` from `open_webui.utils.chat`. If you prefer a lightweight POST request without handling additional parsing (e.g., files, tools, or misc), this utility function is likely what you want.
|
||||
|
||||
#### Example:
|
||||
```python
|
||||
|
||||
# Full API flow with parsing (new function):
|
||||
from open_webui.main import chat_completion
|
||||
|
||||
# Lightweight, direct POST request (direct successor):
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
```
|
||||
|
||||
Choose the approach that best fits your use case!
|
||||
|
||||
4. **Updated Function Signatures.**
|
||||
- Function signatures now adhere to a new format, requiring a `request` object.
|
||||
- The `request` object can be obtained using the `__request__` parameter in the function signature. Below is an example:
|
||||
|
||||
```python
|
||||
class Pipe:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def pipe(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: dict,
|
||||
__request__: Request, # New parameter
|
||||
) -> str:
|
||||
# Write your function here
|
||||
```
|
||||
|
||||
📌 **Why did we make these changes?**
|
||||
- To simplify the codebase, making it easier to extend and maintain.
|
||||
- To unify APIs for a more streamlined developer experience.
|
||||
- To enhance performance by consolidating redundant elements.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Step-by-Step Migration Guide
|
||||
|
||||
Follow this guide to smoothly update your project.
|
||||
|
||||
---
|
||||
|
||||
### 🔄 1. Shifting from `apps` to `routers`
|
||||
|
||||
All apps have been renamed and relocated under `open_webui.routers`. This affects imports in your codebase.
|
||||
|
||||
Quick changes for import paths:
|
||||
|
||||
| **Old Path** | **New Path** |
|
||||
|-----------------------------------|-----------------------------------|
|
||||
| `open_webui.apps.ollama` | `open_webui.routers.ollama` |
|
||||
| `open_webui.apps.openai` | `open_webui.routers.openai` |
|
||||
| `open_webui.apps.audio` | `open_webui.routers.audio` |
|
||||
| `open_webui.apps.retrieval` | `open_webui.routers.retrieval` |
|
||||
| `open_webui.apps.webui` | `open_webui.main` |
|
||||
|
||||
### 📜 An Important Example
|
||||
|
||||
To clarify the special case of the main app (`webui`), here’s a simple rule of thumb:
|
||||
|
||||
- **Was in `webui`?** It’s now in the project’s root or `open_webui.main`.
|
||||
- For example:
|
||||
- **Before (0.4):**
|
||||
```python
|
||||
from open_webui.apps.webui.models import SomeModel
|
||||
```
|
||||
- **After (0.5):**
|
||||
```python
|
||||
from open_webui.models import SomeModel
|
||||
```
|
||||
|
||||
In general, **just replace `open_webui.apps` with `open_webui.routers`—except for `webui`, which is now `open_webui.main`!**
|
||||
|
||||
---
|
||||
|
||||
### 👩💻 2. Updating Import Statements
|
||||
|
||||
Let’s look at what this update looks like in your code:
|
||||
|
||||
#### Before:
|
||||
```python
|
||||
from open_webui.apps.ollama import main as ollama
|
||||
from open_webui.apps.openai import main as openai
|
||||
```
|
||||
|
||||
#### After:
|
||||
```python
|
||||
|
||||
# Separate router imports
|
||||
from open_webui.routers.ollama import generate_chat_completion
|
||||
from open_webui.routers.openai import generate_chat_completion
|
||||
|
||||
# Or use the unified endpoint
|
||||
from open_webui.main import chat_completion
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Prioritize the unified endpoint (`chat_completion`) for simplicity and future compatibility.
|
||||
|
||||
:::
|
||||
|
||||
### 📝 **Additional Note: Choosing Between `main.chat_completion` and `utils.chat.generate_chat_completion`**
|
||||
|
||||
Depending on your use case, you can choose between:
|
||||
|
||||
1. **`open_webui.main.chat_completion`:**
|
||||
- Simulates making a POST request to `/api/chat/completions`.
|
||||
- Processes files, tools, and other miscellaneous tasks.
|
||||
- Best when you want the complete API flow handled automatically.
|
||||
|
||||
2. **`open_webui.utils.chat.generate_chat_completion`:**
|
||||
- Directly makes a POST request without handling extra parsing or tasks.
|
||||
- This is the **direct successor** to the previous `main.generate_chat_completions`, `ollama.generate_chat_completion` and `openai.generate_chat_completion` functions in Open WebUI 0.4.
|
||||
- Best for simplified and more lightweight scenarios.
|
||||
|
||||
#### Example:
|
||||
```python
|
||||
|
||||
# Use this for the full API flow with parsing:
|
||||
from open_webui.main import chat_completion
|
||||
|
||||
# Use this for a stripped-down, direct POST request:
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📋 3. Adapting to Updated Function Signatures
|
||||
|
||||
We’ve updated the **function signatures** to better fit the new architecture. If you're looking for a direct replacement, start with the lightweight utility function `generate_chat_completion` from `open_webui.utils.chat`. For the full API flow, use the new unified `chat_completion` function in `open_webui.main`.
|
||||
|
||||
#### Function Signature Changes:
|
||||
|
||||
| **Old** | **Direct Successor (New)** | **Unified Option (New)** |
|
||||
|-----------------------------------------|-----------------------------------------|-----------------------------------------|
|
||||
| `openai.generate_chat_completion(form_data: dict, user: UserModel)` | `generate_chat_completion(request: Request, form_data: dict, user: UserModel)` | `chat_completion(request: Request, form_data: dict, user: UserModel)` |
|
||||
|
||||
- **Direct Successor (`generate_chat_completion`)**: A lightweight, 1:1 replacement for previous `ollama`/`openai` methods.
|
||||
- **Unified Option (`chat_completion`)**: Use this for the complete API flow, including file parsing and additional functionality.
|
||||
|
||||
#### Example:
|
||||
|
||||
If you're using `chat_completion`, here’s how your function should look now:
|
||||
|
||||
### 🛠️ How to Refactor Your Custom Function
|
||||
Let’s rewrite a sample function to match the new structure:
|
||||
|
||||
#### Before (0.4):
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from open_webui.apps.ollama import generate_chat_completion
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
|
||||
class Pipe:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def pipe(self, body: dict, __user__: dict) -> str:
|
||||
# Calls OpenAI endpoint
|
||||
user = User(**__user__)
|
||||
body["model"] = "llama3.2:latest"
|
||||
return await ollama.generate_chat_completion(body, user)
|
||||
```
|
||||
|
||||
#### After (0.5):
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
|
||||
class Pipe:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def pipe(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: dict,
|
||||
__request__: Request,
|
||||
) -> str:
|
||||
# Uses the unified endpoint with updated signature
|
||||
user = User(**__user__)
|
||||
body["model"] = "llama3.2:latest"
|
||||
return await generate_chat_completion(__request__, body, user)
|
||||
```
|
||||
|
||||
### Important Notes:
|
||||
- You must pass a `Request` object (`__request__`) in the new function signature.
|
||||
- Other optional parameters (like `__user__` and `__event_emitter__`) ensure flexibility for more complex use cases.
|
||||
|
||||
---
|
||||
|
||||
### 🌟 4. Recap: Key Concepts in Simple Terms
|
||||
|
||||
Here’s a quick cheat sheet to remember:
|
||||
- **Apps to Routers:** Update all imports from `open_webui.apps` ➡️ `open_webui.routers`.
|
||||
- **Unified Endpoint:** Use `open_webui.main.chat_completion` for simplicity if both `ollama` and `openai` are involved.
|
||||
- **Adapt Function Signatures:** Ensure your functions pass the required `request` object.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Hooray! You're Ready!
|
||||
|
||||
That's it! You've successfully migrated from **Open WebUI 0.4 to 0.5**. By refactoring your imports, using the unified endpoint, and updating function signatures, you'll be fully equipped to leverage the latest features and improvements in version 0.5.
|
||||
|
||||
---
|
||||
|
||||
💬 **Questions or Feedback?**
|
||||
If you run into any issues or have suggestions, feel free to open a [GitHub issue](https://github.com/open-webui/open-webui) or ask in the community forums!
|
||||
|
||||
Happy coding! ✨
|
||||
1651
docs/features/plugin/tools/development.mdx
Normal file
1651
docs/features/plugin/tools/development.mdx
Normal file
File diff suppressed because it is too large
Load Diff
144
docs/features/plugin/tools/index.mdx
Normal file
144
docs/features/plugin/tools/index.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: "Tools"
|
||||
---
|
||||
|
||||
# ⚙️ What are Tools?
|
||||
|
||||
Tools are small Python scripts that add superpowers to your LLM. When enabled, they allow your chatbot to do amazing things — like search the web, scrape data, generate images, talk back using AI voices, and more.
|
||||
|
||||
Think of Tools as useful plugins that your AI can use when chatting with you.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 What Can Tools Help Me Do?
|
||||
|
||||
Here are just a few examples of what Tools let your AI assistant do:
|
||||
|
||||
- 🌍 Web Search: Get real-time answers by searching the internet.
|
||||
- 🖼️ Image Generation: Create images from your prompts.
|
||||
- 🔊 Voice Output: Generate AI voices using ElevenLabs.
|
||||
|
||||
Explore ready-to-use tools in the 🧰 [Tools Showcase](https://openwebui.com/tools)
|
||||
|
||||
---
|
||||
|
||||
## 📦 How to Install Tools
|
||||
|
||||
There are two easy ways to install Tools in Open WebUI:
|
||||
|
||||
1. Go to [Community Tool Library](https://openwebui.com/tools)
|
||||
2. Choose a Tool, then click the Get button.
|
||||
3. Enter your Open WebUI instance’s IP address or URL.
|
||||
4. Click “Import to WebUI” — done!
|
||||
|
||||
:::warning
|
||||
|
||||
Safety Tip: Never import a Tool you don’t recognize or trust. These are Python scripts and might run unsafe code.
|
||||
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 🔧 How to Use Tools in Open WebUI
|
||||
|
||||
Once you've installed Tools (we’ll show you how below), here’s how to enable and use them:
|
||||
|
||||
You have two ways to enable a Tool for your model:
|
||||
|
||||
### ➕ Option 1: Enable from the Chat Window
|
||||
|
||||
While chatting, click the ➕ icon in the input area. You’ll see a list of available Tools — you can enable any of them on the fly for that session.
|
||||
|
||||
:::tip
|
||||
|
||||
Tip: Enabling a Tool gives the model permission to use it — but it may not use it unless it's useful for the task.
|
||||
|
||||
:::
|
||||
|
||||
### ✏️ Option 2: Enable by Default (Recommended for Frequent Use)
|
||||
1. Go to: Workspace ➡️ Models
|
||||
2. Choose the model you’re using (like GPT-4 or LLaMa2) and click the ✏️ edit icon.
|
||||
3. Scroll down to the “Tools” section.
|
||||
4. ✅ Check the Tools you want your model to have access to by default.
|
||||
5. Click Save.
|
||||
|
||||
This ensures the model always has these Tools ready to use whenever you chat with it.
|
||||
|
||||
You can also let your LLM auto-select the right Tools using the AutoTool Filter:
|
||||
|
||||
🔗 [AutoTool Filter](https://openwebui.com/f/hub/autotool_filter/)
|
||||
|
||||
🎯 Note: Even when using AutoTool, you still need to enable your Tools using Option 2.
|
||||
|
||||
✅ And that’s it — your LLM is now Tool-powered! You're ready to supercharge your chats with web search, image generation, voice output, and more.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Choosing How Tools Are Used: Default vs Native
|
||||
|
||||
Once Tools are enabled for your model, Open WebUI gives you two different ways to let your LLM use them in conversations.
|
||||
|
||||
You can decide how the model should call Tools by choosing between:
|
||||
|
||||
- 🟡 Default Mode (Prompt-based)
|
||||
- 🟢 Native Mode (Built-in function calling)
|
||||
|
||||
Let’s break it down:
|
||||
|
||||
### 🟡 Default Mode (Prompt-based Tool Triggering)
|
||||
|
||||
This is the default setting in Open WebUI.
|
||||
|
||||
Here, your LLM doesn’t need to natively support function calling. Instead, we guide the model using smart tool selection prompt template to select and use a Tool.
|
||||
|
||||
✅ Works with almost any model
|
||||
✅ Great way to unlock Tools with basic or local models
|
||||
❗ Not as reliable or flexible as Native Mode when chaining tools
|
||||
|
||||
### 🟢 Native Mode (Function Calling Built-In)
|
||||
|
||||
If your model does support “native” function calling (like GPT-4o or GPT-3.5-turbo-1106), you can use this powerful mode to let the LLM decide — in real time — when and how to call multiple Tools during a single chat message.
|
||||
|
||||
✅ Fast, accurate, and can chain multiple Tools in one response
|
||||
✅ The most natural and advanced experience
|
||||
❗ Requires a model that actually supports native function calling
|
||||
|
||||
### ✳️ How to Switch Between Modes
|
||||
|
||||
Want to enable native function calling in your chats? Here's how:
|
||||
|
||||

|
||||
|
||||
1. Open the chat window with your model.
|
||||
2. Click ⚙️ Chat Controls > Advanced Params.
|
||||
3. Look for the Function Calling setting and switch it from Default → Native
|
||||
|
||||
That’s it! Your chat is now using true native Tool support (as long as the model supports it).
|
||||
|
||||
➡️ We recommend using GPT-4o or another OpenAI model for the best native function-calling experience.
|
||||
🔎 Some local models may claim support, but often struggle with accurate or complex Tool usage.
|
||||
|
||||
💡 Summary:
|
||||
|
||||
| Mode | Who it’s for | Pros | Cons |
|
||||
|----------|----------------------------------|-----------------------------------------|--------------------------------------|
|
||||
| Default | Any model | Broad compatibility, safer, flexible | May be less accurate or slower |
|
||||
| Native | GPT-4o, etc. | Fast, smart, excellent tool chaining | Needs proper function call support |
|
||||
|
||||
Choose the one that works best for your setup — and remember, you can always switch on the fly via Chat Controls.
|
||||
|
||||
👏 And that's it — your LLM now knows how and when to use Tools, intelligently.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Summary
|
||||
|
||||
Tools are add-ons that help your AI model do much more than just chat. From answering real-time questions to generating images or speaking out loud — Tools bring your AI to life.
|
||||
|
||||
- Visit: [https://openwebui.com/tools](https://openwebui.com/tools) to discover new Tools.
|
||||
- Install them manually or with one-click.
|
||||
- Enable them per model from Workspace ➡️ Models.
|
||||
- Use them in chat by clicking ➕
|
||||
|
||||
Now go make your AI waaaaay smarter 🤖✨
|
||||
176
docs/features/plugin/tools/openapi-servers/faq.mdx
Normal file
176
docs/features/plugin/tools/openapi-servers/faq.mdx
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
title: "FAQ"
|
||||
---
|
||||
|
||||
#### 🌐 Q: Why isn't my local OpenAPI tool server accessible from the WebUI interface?
|
||||
|
||||
**A:** If your tool server is running locally (e.g., http://localhost:8000), browser-based clients may be restricted from accessing it due to CORS (Cross-Origin Resource Sharing) policies.
|
||||
|
||||
Make sure to explicitly enable CORS headers in your OpenAPI server. For example, if you're using FastAPI, you can add:
|
||||
|
||||
```python
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # or specify your client origin
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
Also, if Open WebUI is served over HTTPS (e.g., https://yourdomain.com), your local server must meet one of the following conditions:
|
||||
|
||||
- Be accessed from the same domain using HTTPS (e.g., https://localhost:8000).
|
||||
- OR run on localhost (127.0.0.1) to allow browsers to relax security for local development.
|
||||
- Otherwise, browsers may block insecure requests from HTTPS pages to HTTP APIs due to mixed-content rules.
|
||||
|
||||
To work securely in production over HTTPS, your OpenAPI servers must also be served over HTTPS.
|
||||
|
||||
---
|
||||
|
||||
#### 🚀 Q: Do I need to use FastAPI for my server implementation?
|
||||
|
||||
**A:** No! While our reference implementations are written using FastAPI for clarity and ease of use, you can use any framework or language that produces a valid OpenAPI (Swagger) specification. Some common choices include:
|
||||
|
||||
- FastAPI (Python)
|
||||
- Flask + Flask-RESTX (Python)
|
||||
- Express + Swagger UI (JavaScript/Node)
|
||||
- Spring Boot (Java)
|
||||
- Go with Swag or Echo
|
||||
|
||||
The key is to ensure your server exposes a valid OpenAPI schema, and that it communicates over HTTP(S).
|
||||
It is important to set a custom operationId for all endpoints.
|
||||
|
||||
---
|
||||
|
||||
#### 🚀 Q: Why choose OpenAPI over MCP?
|
||||
|
||||
**A:** OpenAPI wins over MCP in most real-world scenarios due to its simplicity, tooling ecosystem, stability, and developer-friendliness. Here's why:
|
||||
|
||||
- ✅ **Reuse Your Existing Code**: If you’ve built REST APIs before, you're mostly done—you don’t need to rewrite your logic. Just define a compliant OpenAPI spec and expose your current code as a tool server.
|
||||
|
||||
With MCP, you had to reimplement your tool logic inside a custom protocol layer, duplicating work and increasing the surface area to maintain.
|
||||
|
||||
- 💼 **Less to Maintain & Debug**: OpenAPI fits naturally into modern dev workflows. You can test endpoints with Postman, inspect logs with built-in APIs, troubleshoot easily with mature ecosystem tools—and often without modifying your core app at all.
|
||||
|
||||
MCP introduced new layers of transport, schema parsing, and runtime quirks, all of which had to be debugged manually.
|
||||
|
||||
- 🌍 **Standards-Based**: OpenAPI is widely adopted across the tech industry. Its well-defined structure means tools, agents, and servers can interoperate immediately, without needing special bridges or translations.
|
||||
|
||||
- 🧰 **Better Tooling**: There’s an entire universe of tools that support OpenAPI—automatic client/server generation, documentation, validation, mocking, testing, and even security audit tools.
|
||||
|
||||
- 🔐 **First-Class Security Support**: OpenAPI includes native support for things like OAuth2, JWTs, API Keys, and HTTPS—making it easier to build secure endpoints with common libraries and standards.
|
||||
|
||||
- 🧠 **More Devs Already Know It**: Using OpenAPI means you're speaking a language already familiar to backend teams, frontend developers, DevOps, and product engineers. There’s no learning curve or costly onboarding required.
|
||||
|
||||
- 🔄 **Future-Proof & Extensible**: OpenAPI evolves with API standards and remains forward-compatible. MCP, by contrast, was bespoke and experimental—often requiring changes as the surrounding ecosystem changed.
|
||||
|
||||
🧵 Bottom line: OpenAPI lets you do more with less effort, less code duplication, and fewer surprises. It’s a production-ready, developer-friendly route to powering LLM tools—without rebuilding everything from scratch.
|
||||
|
||||
---
|
||||
|
||||
#### 🔐 Q: How do I secure my OpenAPI tool server?
|
||||
|
||||
**A:** OpenAPI supports industry-standard security mechanisms like:
|
||||
|
||||
- OAuth 2.0
|
||||
- API Key headers
|
||||
- JWT (JSON Web Token)
|
||||
- Basic Auth
|
||||
|
||||
Use HTTPS in production to encrypt data in transit, and restrict endpoints with proper auth/authz methods as appropriate. You can incorporate these directly in your OpenAPI schema using the securitySchemes field.
|
||||
|
||||
---
|
||||
|
||||
#### ❓ Q: What kind of tools can I build using OpenAPI tool servers?
|
||||
|
||||
**A:** If it can be exposed via a REST API, you can build it. Common tool types include:
|
||||
|
||||
- Filesystem operations (read/write files, list directories)
|
||||
- Git and document repository access
|
||||
- Database querying or schema exploration
|
||||
- Web scrapers or summarizers
|
||||
- External SaaS integrations (e.g., Salesforce, Jira, Slack)
|
||||
- LLM-attached memory stores / RAG components
|
||||
- Secure internal microservices exposed to your agent
|
||||
|
||||
---
|
||||
|
||||
#### 🔌 Q: Can I run more than one tool server at the same time?
|
||||
|
||||
**A:** Absolutely. Each tool server runs independently and exposes its own OpenAPI schema. Your agent configuration can point to multiple tool servers, allowing you to mix and match based on need.
|
||||
|
||||
There's no limit—just ensure each server runs on its own port or address and is reachable by the agent host.
|
||||
|
||||
---
|
||||
|
||||
#### 🧪 Q: How do I test a tool server before linking it to an LLM agent?
|
||||
|
||||
**A:** You can test your OpenAPI tool servers using:
|
||||
|
||||
- Swagger UI or ReDoc (built into FastAPI by default)
|
||||
- Postman or Insomnia
|
||||
- curl or httpie from the command line
|
||||
- Python’s requests module
|
||||
- OpenAPI validators and mockers
|
||||
|
||||
Once validated, you can register the tool server with an LLM agent or through Open WebUI.
|
||||
|
||||
---
|
||||
|
||||
#### 🛠️ Q: Can I extend or customize the reference servers?
|
||||
|
||||
**A:** Yes! All servers in the servers/ directory are built to be simple templates. Fork and modify them to:
|
||||
|
||||
- Add new endpoints and business logic
|
||||
- Integrate authentication
|
||||
- Change response formats
|
||||
- Connect to new services or internal APIs
|
||||
- Deploy via Docker, Kubernetes, or any cloud host
|
||||
|
||||
---
|
||||
|
||||
#### 🌍 Q: Can I run OpenAPI tool servers on cloud platforms like AWS or GCP?
|
||||
|
||||
**A:** Yes. These servers are plain HTTP services. You can deploy them as:
|
||||
|
||||
- AWS Lambda with API Gateway (serverless)
|
||||
- EC2 or GCP Compute Engine instances
|
||||
- Kubernetes services in GKE/EKS/AKS
|
||||
- Cloud Run or App Engine
|
||||
- Render, Railway, Heroku, etc.
|
||||
|
||||
Just make sure they’re securely configured and publicly reachable (or VPN'd) if needed by the agent or user.
|
||||
|
||||
---
|
||||
|
||||
#### 🧪 Q: What if I have an existing MCP server?
|
||||
|
||||
**A:** Great news! You can use our MCP-to-OpenAPI Bridge: [mcpo](https://github.com/open-webui/mcpo), exposing your existing MCP-based tools as OpenAPI-compatible APIs is now easier than ever. No rewrites, no headaches — just plug and go! 🚀
|
||||
|
||||
If you've already built tools using the MCP protocol, `mcpo` helps you instantly unlock compatibility with Open WebUI and any OpenAPI-based agent — ensuring your hard work remains fully accessible and future-ready.
|
||||
|
||||
[Check out the optional Bridge to MCP section in the docs for setup instructions.](https://github.com/open-webui/openapi-servers?tab=readme-ov-file#-bridge-to-mcp-optional)
|
||||
|
||||
**Quick Start:**
|
||||
```bash
|
||||
uvx mcpo --port 8000 -- uvx mcp-server-time --local-timezone=America/New_York
|
||||
```
|
||||
|
||||
✨ That’s it — your MCP server is now OpenAPI-ready!
|
||||
|
||||
---
|
||||
|
||||
#### 🗂️ Q: Can one OpenAPI server implement multiple tools?
|
||||
|
||||
**A:** Yes. A single OpenAPI server can offer multiple related capabilities grouped under different endpoints. For example, a document server may provide search, upload, OCR, and summarization—all within one schema.
|
||||
|
||||
You can also modularize completely by creating one OpenAPI server per tool if you prefer isolation and flexibility.
|
||||
|
||||
---
|
||||
|
||||
🙋 Have more questions? Visit the GitHub discussions for help and feedback from the community:
|
||||
👉 [Community Discussions](https://github.com/open-webui/openapi-servers/discussions)
|
||||
70
docs/features/plugin/tools/openapi-servers/index.mdx
Normal file
70
docs/features/plugin/tools/openapi-servers/index.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
sidebar_position: 400
|
||||
title: "OpenAPI Tool Servers"
|
||||
---
|
||||
|
||||
import { TopBanners } from "@site/src/components/TopBanners";
|
||||
|
||||
<TopBanners />
|
||||
|
||||
# 🌟 OpenAPI Tool Servers
|
||||
|
||||
This repository provides reference OpenAPI Tool Server implementations making it easy and secure for developers to integrate external tooling and data sources into LLM agents and workflows. Designed for maximum ease of use and minimal learning curve, these implementations utilize the widely adopted and battle-tested [OpenAPI specification](https://www.openapis.org/) as the standard protocol.
|
||||
|
||||
By leveraging OpenAPI, we eliminate the need for a proprietary or unfamiliar communication protocol, ensuring you can quickly and confidently build or integrate servers. This means less time spent figuring out custom interfaces and more time building powerful tools that enhance your AI applications.
|
||||
|
||||
## ☝️ Why OpenAPI?
|
||||
|
||||
- **Established Standard**: OpenAPI is a widely used, production-proven API standard backed by thousands of tools, companies, and communities.
|
||||
|
||||
- **No Reinventing the Wheel**: No additional documentation or proprietary spec confusion. If you build REST APIs or use OpenAPI today, you're already set.
|
||||
|
||||
- **Easy Integration & Hosting**: Deploy your tool servers externally or locally without vendor lock-in or complex configurations.
|
||||
|
||||
- **Strong Security Focus**: Built around HTTP/REST APIs, OpenAPI inherently supports widely used, secure communication methods including HTTPS and well-proven authentication standards (OAuth, JWT, API Keys).
|
||||
|
||||
- **Future-Friendly & Stable**: Unlike less mature or experimental protocols, OpenAPI promises reliability, stability, and long-term community support.
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
Get started quickly with our reference FastAPI-based implementations provided in the `servers/` directory. (You can adapt these examples into your preferred stack as needed, such as using [FastAPI](https://fastapi.tiangolo.com/), [FastOpenAPI](https://github.com/mr-fatalyst/fastopenapi) or any other OpenAPI-compatible library):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-webui/openapi-servers
|
||||
cd openapi-servers
|
||||
```
|
||||
|
||||
### With Bash
|
||||
|
||||
```bash
|
||||
|
||||
# Example: Installing dependencies for a specific server 'filesystem'
|
||||
cd servers/filesystem
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --host 0.0.0.0 --reload
|
||||
```
|
||||
|
||||
The filesystem server should be reachable from: [http://localhost:8000](http://localhost:8000)
|
||||
|
||||
The documentation path will be: [http://localhost:8000](http://localhost:8000)
|
||||
|
||||
### With Docker
|
||||
|
||||
If you have docker compose installed, bring the servers up with:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The services will be reachable from:
|
||||
|
||||
* [Filesystem localhost:8081](http://localhost:8081)
|
||||
* [memory server localhost:8082](http://localhost:8082)
|
||||
* [time-server localhost:8083](http://localhost:8083)
|
||||
|
||||
Now, simply point your OpenAPI-compatible clients or AI agents to your local or publicly deployed URL—no configuration headaches, no complicated transports.
|
||||
|
||||
## 🌱 Open WebUI Community
|
||||
|
||||
- For general discussions, technical exchange, and announcements, visit our [Community Discussions](https://github.com/open-webui/openapi-servers/discussions) page.
|
||||
- Have ideas or feedback? Please open an issue!
|
||||
199
docs/features/plugin/tools/openapi-servers/mcp.mdx
Normal file
199
docs/features/plugin/tools/openapi-servers/mcp.mdx
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "MCP Support"
|
||||
---
|
||||
|
||||
This documentation explains how to easily set up and deploy the [**MCP (Model Context Protocol)-to-OpenAPI proxy server** (mcpo)](https://github.com/open-webui/mcpo) provided by Open WebUI. Learn how you can effortlessly expose MCP-based tool servers using standard, familiar OpenAPI endpoints suitable for end-users and developers.
|
||||
|
||||
### 📌 What is the MCP Proxy Server?
|
||||
|
||||
The MCP-to-OpenAPI proxy server lets you use tool servers implemented with MCP (Model Context Protocol) directly via standard REST/OpenAPI APIs—no need to manage unfamiliar or complicated custom protocols. If you're an end-user or application developer, this means you can interact easily with powerful MCP-based tooling directly through familiar REST-like endpoints.
|
||||
|
||||
### 💡 Why Use mcpo?
|
||||
|
||||
While MCP tool servers are powerful and flexible, they commonly communicate via standard input/output (stdio)—often running on your local machine where they can easily access your filesystem, environment, and other native system capabilities.
|
||||
|
||||
That’s a strength—but also a limitation.
|
||||
|
||||
If you want to deploy your main interface (like Open WebUI) on the cloud, you quickly run into a problem: your cloud instance can’t speak directly to an MCP server running locally on your machine via stdio.
|
||||
|
||||
[That’s where mcpo comes in with a game-changing solution.](https://github.com/open-webui/mcpo)
|
||||
|
||||
MCP servers typically rely on raw stdio communication, which is:
|
||||
|
||||
- 🔓 Inherently insecure across environments
|
||||
- ❌ Incompatible with most modern tools, UIs, or platforms
|
||||
- 🧩 Lacking critical features like authentication, documentation, and error handling
|
||||
|
||||
The mcpo proxy eliminates those issues—automatically:
|
||||
|
||||
- ✅ Instantly compatible with existing OpenAPI tools, SDKs, and clients
|
||||
- 🛡 Wraps your tools with secure, scalable, and standards-based HTTP endpoints
|
||||
- 🧠 Auto-generates interactive OpenAPI documentation for every tool, entirely config-free
|
||||
- 🔌 Uses plain HTTP—no socket setup, daemon juggling, or platform-specific glue code
|
||||
|
||||
So even though adding mcpo might at first seem like "just one more layer"—in reality, it simplifies everything while giving you:
|
||||
|
||||
- Better integration ✅
|
||||
- Better security ✅
|
||||
- Better scalability ✅
|
||||
- Happier developers & users ✅
|
||||
|
||||
✨ With mcpo, your local-only AI tools become cloud-ready, UI-friendly, and instantly interoperable—without changing a single line of tool server code.
|
||||
|
||||
### ✅ Quickstart: Running the Proxy Locally
|
||||
|
||||
Here's how simple it is to launch the MCP-to-OpenAPI proxy server using the lightweight, easy-to-use tool **mcpo** ([GitHub Repository](https://github.com/open-webui/mcpo)):
|
||||
|
||||
1. **Prerequisites**
|
||||
- **Python 3.8+** with `pip` installed.
|
||||
- MCP-compatible application (for example: `mcp-server-time`)
|
||||
- (Optional but recommended) `uv` installed for faster startup and zero-config convenience.
|
||||
|
||||
2. **Install mcpo**
|
||||
|
||||
Using **uv** (recommended):
|
||||
|
||||
```bash
|
||||
uvx mcpo --port 8000 -- your_mcp_server_command
|
||||
```
|
||||
|
||||
Or using `pip`:
|
||||
|
||||
```bash
|
||||
pip install mcpo
|
||||
mcpo --port 8000 -- your_mcp_server_command
|
||||
```
|
||||
|
||||
3. 🚀 **Run the Proxy Server**
|
||||
|
||||
To start your MCP-to-OpenAPI proxy server, you need an MCP-compatible tool server. If you don't have one yet, the MCP community provides various ready-to-use MCP server implementations.
|
||||
|
||||
✨ **Where to find MCP Servers?**
|
||||
|
||||
You can discover officially supported MCP servers at the following repository example:
|
||||
|
||||
- [modelcontextprotocol/servers on GitHub](https://github.com/modelcontextprotocol/servers)
|
||||
|
||||
For instance, the popular **Time MCP Server** is documented [here](https://github.com/modelcontextprotocol/servers/blob/main/src/time/README.md), and is typically referenced clearly in the README, inside the provided MCP configuration. Specifically, the README states:
|
||||
|
||||
> Add to your Claude settings:
|
||||
>
|
||||
> ```json
|
||||
> "mcpServers": {
|
||||
> "time": {
|
||||
> "command": "uvx",
|
||||
> "args": ["mcp-server-time", "--local-timezone=America/New_York"]
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
|
||||
🔑 **Translating this MCP setup to a quick local proxy command**:
|
||||
|
||||
You can easily run the recommended MCP server (`mcp-server-time`) directly through the **MCP-to-OpenAPI proxy** (`mcpo`) like this:
|
||||
|
||||
```bash
|
||||
uvx mcpo --port 8000 -- uvx mcp-server-time --local-timezone=America/New_York
|
||||
```
|
||||
|
||||
That's it! You're now running the MCP-to-OpenAPI Proxy locally and exposing the powerful **MCP Time Server** through standard OpenAPI endpoints accessible at:
|
||||
|
||||
- 📖 **Interactive OpenAPI Documentation:** [`http://localhost:8000/docs`](http://localhost:8000/docs)
|
||||
|
||||
Feel free to replace `uvx mcp-server-time --local-timezone=America/New_York` with your preferred MCP Server command from other available MCP implementations found in the official repository.
|
||||
|
||||
🤝 **To integrate with Open WebUI after launching the server, check our [docs](https://docs.openwebui.com/openapi-servers/open-webui/).**
|
||||
|
||||
### 🚀 Accessing the Generated APIs
|
||||
|
||||
As soon as it starts, the MCP Proxy (`mcpo`) automatically:
|
||||
|
||||
- Discovers MCP tools dynamically and generates REST endpoints.
|
||||
- Creates interactive, human-readable OpenAPI documentation accessible at:
|
||||
- `http://localhost:8000/docs`
|
||||
|
||||
Simply call the auto-generated API endpoints directly via HTTP clients, AI agents, or other OpenAPI tools of your preference.
|
||||
|
||||
### 📖 Example Workflow for End-Users
|
||||
|
||||
Assuming you started the above server command (`uvx mcp-server-time`):
|
||||
|
||||
- Visit your local API documentation at `http://localhost:8000/docs`.
|
||||
- Select a generated endpoint (e.g., `/get_current_time`) and use the provided interactive form.
|
||||
- Click "**Execute**" and instantly receive your response.
|
||||
|
||||
No setup complexity—just instant REST APIs.
|
||||
|
||||
## 🚀 Deploying in Production (Example)
|
||||
|
||||
Deploying your MCP-to-OpenAPI proxy (powered by mcpo) is straightforward. Here's how to easily Dockerize and deploy it to cloud or VPS solutions:
|
||||
|
||||
### 🐳 Dockerize your Proxy Server using mcpo
|
||||
|
||||
1. **Dockerfile Example**
|
||||
|
||||
Create the following `Dockerfile` inside your deployment directory:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN pip install mcpo uv
|
||||
|
||||
# Replace with your MCP server command; example: uvx mcp-server-time
|
||||
CMD ["uvx", "mcpo", "--host", "0.0.0.0", "--port", "8000", "--", "uvx", "mcp-server-time", "--local-timezone=America/New_York"]
|
||||
```
|
||||
|
||||
2. **Build & Run the Container Locally**
|
||||
|
||||
```bash
|
||||
docker build -t mcp-proxy-server .
|
||||
docker run -d -p 8000:8000 mcp-proxy-server
|
||||
```
|
||||
|
||||
3. **Deploying Your Container**
|
||||
|
||||
Push to DockerHub or another registry:
|
||||
|
||||
```bash
|
||||
docker tag mcp-proxy-server yourdockerusername/mcp-proxy-server:latest
|
||||
docker push yourdockerusername/mcp-proxy-server:latest
|
||||
```
|
||||
|
||||
Deploy using Docker Compose, Kubernetes YAML manifests, or your favorite cloud container services (AWS ECS, Azure Container Instances, Render.com, or Heroku).
|
||||
|
||||
✔️ Your production MCP servers are now effortlessly available via REST APIs!
|
||||
|
||||
## 🧑💻 Technical Details and Background
|
||||
|
||||
### 🍃 How It Works (Technical Summary)
|
||||
|
||||
- **Dynamic Schema Discovery & Endpoints:** At server startup, the proxy connects to the MCP server to query available tools. It automatically builds FastAPI endpoints based on the MCP tool schemas, creating concise and clear REST endpoints.
|
||||
|
||||
- **OpenAPI Auto-documentation:** Endpoints generated are seamlessly documented and available via FastAPI's built-in Swagger UI (`/docs`). No extra doc writing required.
|
||||
|
||||
- **Asynchronous & Performant**: Built on robust asynchronous libraries, ensuring speed and reliability for concurrent users.
|
||||
|
||||
### 📚 Under the Hood:
|
||||
|
||||
- FastAPI (Automatic routing & docs generation)
|
||||
- MCP Client (Standard MCP integration & schema discovery)
|
||||
- Standard JSON over HTTP (Easy integration)
|
||||
|
||||
## ⚡️ Why is the MCP-to-OpenAPI Proxy Superior?
|
||||
|
||||
Here's why leveraging MCP servers through OpenAPI via the proxy approach is significantly better and why Open WebUI enthusiastically supports it:
|
||||
|
||||
- **User-friendly & Familiar Interface**: No custom clients; just HTTP REST endpoints you already know.
|
||||
- **Instant Integration**: Immediately compatible with thousands of existing REST/OpenAPI tools, SDKs, and services.
|
||||
- **Powerful & Automatic Docs**: Built-in Swagger UI documentation is automatically generated, always accurate, and maintained.
|
||||
- **No New Protocol overhead**: Eliminates the necessity to directly handle MCP-specific protocol complexities and socket communication issues.
|
||||
- **Battle-Tested Security & Stability**: Inherits well-established HTTPS transport, standard auth methods (JWT, API keys), solid async libraries, and FastAPI’s proven robustness.
|
||||
- **Future-Proof**: MCP proxy uses existing, stable, standard REST/OpenAPI formats guaranteed long-term community support and evolution.
|
||||
|
||||
🌟 **Bottom line:** MCP-to-OpenAPI makes your powerful MCP-based AI tools broadly accessible through intuitive, reliable, and scalable REST endpoints. Open WebUI proudly supports and recommends this best-in-class approach.
|
||||
|
||||
## 📢 Community & Support
|
||||
|
||||
- For questions, suggestions, or feature requests, please use our [GitHub Issue tracker](https://github.com/open-webui/openapi-servers/issues) or join our [Community Discussions](https://github.com/open-webui/openapi-servers/discussions).
|
||||
|
||||
Happy integrations! 🌟🚀
|
||||
211
docs/features/plugin/tools/openapi-servers/open-webui.mdx
Normal file
211
docs/features/plugin/tools/openapi-servers/open-webui.mdx
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: "Open WebUI Integration"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Open WebUI v0.6+ supports seamless integration with external tools via the OpenAPI servers — meaning you can easily extend your LLM workflows using custom or community-powered tool servers 🧰.
|
||||
|
||||
In this guide, you'll learn how to launch an OpenAPI-compatible tool server and connect it to Open WebUI through the intuitive user interface. Let’s get started! 🚀
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Launch an OpenAPI Tool Server
|
||||
|
||||
To begin, you'll need to start one of the reference tool servers available in the [openapi-servers repo](https://github.com/open-webui/openapi-servers). For quick testing, we’ll use the time tool server as an example.
|
||||
|
||||
🛠️ Example: Starting the `time` server locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-webui/openapi-servers
|
||||
cd openapi-servers
|
||||
|
||||
# Navigate to the time server
|
||||
cd servers/time
|
||||
|
||||
# Install required dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Start the server
|
||||
uvicorn main:app --host 0.0.0.0 --reload
|
||||
```
|
||||
|
||||
Once running, this will host a local OpenAPI server at http://localhost:8000, which you can point Open WebUI to.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Step 2: Connect Tool Server in Open WebUI
|
||||
|
||||
Next, connect your running tool server to Open WebUI:
|
||||
|
||||
1. Open WebUI in your browser.
|
||||
2. Open ⚙️ **Settings**.
|
||||
3. Click on ➕ **Tools** to add a new tool server.
|
||||
4. Enter the URL where your OpenAPI tool server is running (e.g., http://localhost:8000).
|
||||
5. Click "Save".
|
||||
|
||||

|
||||
|
||||
### 🧑💻 User Tool Servers vs. 🛠️ Global Tool Servers
|
||||
|
||||
There are two ways to register tool servers in Open WebUI:
|
||||
|
||||
#### 1. User Tool Servers (added via regular Settings)
|
||||
|
||||
- Only accessible to the user who registered the tool server.
|
||||
- The connection is made directly from the browser (client-side) by the user.
|
||||
- Perfect for personal workflows or when testing custom/local tools.
|
||||
|
||||
#### 2. Global Tool Servers (added via Admin Settings)
|
||||
|
||||
Admins can manage shared tool servers available to all or selected users across the entire deployment:
|
||||
|
||||
- Go to 🛠️ **Admin Settings > Tools**.
|
||||
- Add the tool server URL just as you would in user settings.
|
||||
- These tools are treated similarly to Open WebUI’s built-in tools.
|
||||
|
||||
#### Main Difference: Where Are Requests Made From?
|
||||
|
||||
The primary distinction between **User Tool Servers** and **Global Tool Servers** is where the API connection and requests are actually made:
|
||||
|
||||
- **User Tool Servers**
|
||||
- Requests to the tool server are performed **directly from your browser** (the client).
|
||||
- This means you can safely connect to localhost URLs (like `http://localhost:8000`)—even exposing private or development-only endpoints such as your local filesystem or dev tools—without risking exposure to the wider internet or other users.
|
||||
- Your connection is isolated; only your browser can access that tool server.
|
||||
|
||||
- **Global Tool Servers**
|
||||
- Requests are sent **from the Open WebUI backend/server** (not your browser).
|
||||
- The backend must be able to reach the tool server URL you specify—so `localhost` means the backend server's localhost, *not* your computer's.
|
||||
- Use this for sharing tools with other users across the deployment, but be mindful: since the backend makes the requests, you cannot access your personal local resources (like your own filesystem) through this method.
|
||||
- Think security! Only expose remote/global endpoints that are safe and meant to be accessed by multiple users.
|
||||
|
||||
**Summary Table:**
|
||||
|
||||
| Tool Server Type | Request Origin | Use Localhost? | Use Case Example |
|
||||
| ------------------ | -------------------- | ------------------ | ---------------------------------------- |
|
||||
| User Tool Server | User's Browser (Client-side) | Yes (private to you) | Personal tools, local dev/testing |
|
||||
| Global Tool Server | Open WebUI Backend (Server-side) | No (unless running on the backend itself) | Team/shared tools, enterprise integrations |
|
||||
|
||||
:::tip
|
||||
|
||||
User Tool Servers are best for personal or experimental tools, especially those running on your own machine, while Global Tool Servers are ideal for production or shared environments where everyone needs access to the same tools.
|
||||
|
||||
:::
|
||||
|
||||
### 👉 Optional: Using a Config File with mcpo
|
||||
|
||||
If you're running multiple tools through mcpo using a config file, take note:
|
||||
|
||||
🧩 Each tool is mounted under its own unique path!
|
||||
|
||||
For example, if you’re using memory and time tools simultaneously through mcpo, they’ll each be available at a distinct route:
|
||||
|
||||
- http://localhost:8000/time
|
||||
- http://localhost:8000/memory
|
||||
|
||||
This means:
|
||||
|
||||
- When connecting a tool in Open WebUI, you must enter the full route to that specific tool — do NOT enter just the root URL (http://localhost:8000).
|
||||
- Add each tool individually in Open WebUI Settings using their respective subpath URLs.
|
||||
|
||||

|
||||
|
||||
✅ Good:
|
||||
|
||||
http://localhost:8000/time
|
||||
http://localhost:8000/memory
|
||||
|
||||
🚫 Not valid:
|
||||
|
||||
http://localhost:8000
|
||||
|
||||
This ensures Open WebUI recognizes and communicates with each tool server correctly.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Confirm Your Tool Server Is Connected ✅
|
||||
|
||||
Once your tool server is successfully connected, Open WebUI will display a 👇 tool server indicator directly in the message input area:
|
||||
|
||||
📍 You'll now see this icon below the input box:
|
||||
|
||||

|
||||
|
||||
Clicking this icon opens a popup where you can:
|
||||
|
||||
- View connected tool server information
|
||||
- See which tools are available and which server they're provided by
|
||||
- Debug or disconnect any tool if needed
|
||||
|
||||
🔍 Here’s what the tool information modal looks like:
|
||||
|
||||

|
||||
|
||||
### 🛠️ Global Tool Servers Look Different — And Are Hidden by Default!
|
||||
|
||||
If you've connected a Global Tool Server (i.e., one that’s admin-configured), it will not appear automatically in the input area like user tool servers do.
|
||||
|
||||
Instead:
|
||||
|
||||
- Global tools are hidden by default and must be explicitly activated per user.
|
||||
- To enable them, you'll need to click on the ➕ button in the message input area (bottom left of the chat box), and manually toggle on the specific global tool(s) you want to use.
|
||||
|
||||
Here’s what that looks like:
|
||||
|
||||

|
||||
|
||||
⚠️ Important Notes for Global Tool Servers:
|
||||
|
||||
- They will not show up in the tool indicator popup until enabled from the ➕ menu.
|
||||
- Each global tool must be individually toggled on to become active inside your current chat.
|
||||
- Once toggled on, they function the same way as user tools.
|
||||
- Admins can control access to global tools via role-based permissions.
|
||||
|
||||
This is ideal for team setups or shared environments, where commonly-used tools (e.g., document search, memory, or web lookup) should be centrally accessible by multiple users.
|
||||
|
||||
---
|
||||
|
||||
## (Optional) Step 4: Use "Native" Function Calling (ReACT-style) Tool Use 🧠
|
||||
|
||||
:::info
|
||||
|
||||
For this to work effectively, **your selected model must support native tool calling**. Some local models claim support but often produce poor results. We strongly recommend using GPT-4o or another OpenAI model that supports function calling natively for the best experience.
|
||||
|
||||
:::
|
||||
|
||||
Want to enable ReACT-style (Reasoning + Acting) native function calls directly inside your conversations? You can switch Open WebUI to use native function calling.
|
||||
|
||||
✳️ How to enable native function calling:
|
||||
|
||||
1. Open the chat window.
|
||||
2. Go to ⚙️ **Chat Controls > Advanced Params**.
|
||||
3. Change the **Function Calling** parameter from `Default` to `Native`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Need More Tools? Explore & Expand! 🧱
|
||||
|
||||
The [openapi-servers repo](https://github.com/open-webui/openapi-servers) includes a variety of useful reference servers:
|
||||
|
||||
- 📂 Filesystem access
|
||||
- 🧠 Memory & knowledge graphs
|
||||
- 🗃️ Git repo browsing
|
||||
- 🌎 Web search (WIP)
|
||||
- 🛢️ Database querying (WIP)
|
||||
|
||||
You can run any of these in the same way and connect them to Open WebUI by repeating the steps above.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting & Tips 🧩
|
||||
|
||||
- ❌ Not connecting? Make sure the URL is correct and accessible from the browser used to run Open WebUI.
|
||||
- 🔒 If you're using remote servers, check firewalls and HTTPS configs!
|
||||
- 📝 To make servers persist, consider deploying them in Docker or with system services.
|
||||
|
||||
Need help? Visit the 👉 [Discussions page](https://github.com/open-webui/openapi-servers/discussions) or [open an issue](https://github.com/open-webui/openapi-servers/issues).
|
||||
2562
docs/zh/future_plugin_development_roadmap_cn.md
Normal file
2562
docs/zh/future_plugin_development_roadmap_cn.md
Normal file
File diff suppressed because it is too large
Load Diff
234
docs/zh/plugin_development_guide.md
Normal file
234
docs/zh/plugin_development_guide.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# OpenWebUI 插件开发权威指南
|
||||
|
||||
> 本指南整合了官方文档、SDK 详解及最佳实践,旨在为开发者提供一份从入门到精通的系统化教程。
|
||||
|
||||
## 📚 目录
|
||||
|
||||
1. [插件开发快速入门](#1-插件开发快速入门)
|
||||
2. [核心概念与 SDK 详解](#2-核心概念与-sdk-详解)
|
||||
3. [插件类型深度解析](#3-插件类型深度解析)
|
||||
* [Action (动作)](#31-action-动作)
|
||||
* [Filter (过滤器)](#32-filter-过滤器)
|
||||
* [Pipe (管道)](#33-pipe-管道)
|
||||
4. [高级开发模式](#4-高级开发模式)
|
||||
5. [最佳实践与设计原则](#5-最佳实践与设计原则)
|
||||
6. [故障排查](#6-故障排查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 插件开发快速入门
|
||||
|
||||
### 1.1 什么是 OpenWebUI 插件?
|
||||
|
||||
OpenWebUI 插件(官方称为 "Functions")是扩展平台功能的主要方式。它们运行在后端 Python 环境中,允许你:
|
||||
* 🔌 **集成新模型**:通过 Pipe 接入 Claude、Gemini 或自定义 RAG。
|
||||
* 🎨 **增强交互**:通过 Action 在消息旁添加按钮(如"导出"、"生成图表")。
|
||||
* 🔧 **干预流程**:通过 Filter 在请求前后修改数据(如注入上下文、敏感词过滤)。
|
||||
|
||||
### 1.2 你的第一个插件 (Hello World)
|
||||
|
||||
保存以下代码为 `hello.py` 并上传到 OpenWebUI 的 **Functions** 面板:
|
||||
|
||||
```python
|
||||
"""
|
||||
title: Hello World Action
|
||||
author: Demo
|
||||
version: 1.0.0
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
greeting: str = Field(default="你好", description="问候语")
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__event_emitter__=None,
|
||||
__user__=None
|
||||
) -> Optional[dict]:
|
||||
user_name = __user__.get("name", "朋友") if __user__ else "朋友"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "success", "content": f"{self.valves.greeting}, {user_name}!"}
|
||||
})
|
||||
return body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心概念与 SDK 详解
|
||||
|
||||
### 2.1 ⚠️ 重要:同步与异步
|
||||
|
||||
OpenWebUI 插件运行在 `asyncio` 事件循环中。
|
||||
* **原则**:所有 I/O 操作(数据库、文件、网络)必须非阻塞。
|
||||
* **陷阱**:直接调用同步方法(如 `time.sleep`, `requests.get`)会卡死整个服务器。
|
||||
* **解决**:使用 `await asyncio.to_thread(sync_func, ...)` 包装同步调用。
|
||||
|
||||
### 2.2 核心参数详解
|
||||
|
||||
所有插件方法(`inlet`, `outlet`, `pipe`, `action`)都支持注入以下特殊参数:
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `body` | `dict` | **核心数据**。包含 `messages`, `model`, `stream` 等请求信息。 |
|
||||
| `__user__` | `dict` | **当前用户**。包含 `id`, `name`, `role`, `valves` (用户配置) 等。 |
|
||||
| `__metadata__` | `dict` | **元数据**。包含 `chat_id`, `message_id`。其中 `variables` 字段包含 `{{USER_NAME}}`, `{{CURRENT_TIME}}` 等预置变量。 |
|
||||
| `__request__` | `Request` | **FastAPI 请求对象**。可访问 `app.state` 进行跨插件通信。 |
|
||||
| `__event_emitter__` | `func` | **单向通知**。用于发送 Toast 通知或状态条更新。 |
|
||||
| `__event_call__` | `func` | **双向交互**。用于在前端执行 JS 代码、弹出确认框或输入框。 |
|
||||
|
||||
### 2.3 配置系统 (Valves)
|
||||
|
||||
* **`Valves`**: 管理员全局配置。
|
||||
* **`UserValves`**: 用户级配置(优先级更高,可覆盖全局)。
|
||||
|
||||
```python
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
API_KEY: str = Field(default="", description="全局 API Key")
|
||||
|
||||
class UserValves(BaseModel):
|
||||
API_KEY: str = Field(default="", description="用户私有 API Key")
|
||||
|
||||
def inlet(self, body, __user__):
|
||||
# 优先使用用户的 Key
|
||||
user_valves = __user__.get("valves", self.UserValves())
|
||||
api_key = user_valves.API_KEY or self.valves.API_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 插件类型深度解析
|
||||
|
||||
### 3.1 Action (动作)
|
||||
|
||||
**定位**:在消息下方添加按钮,用户点击触发。
|
||||
|
||||
**高级用法:前端执行 JavaScript (文件下载示例)**
|
||||
|
||||
```python
|
||||
import base64
|
||||
|
||||
async def action(self, body, __event_call__):
|
||||
# 1. 后端生成内容
|
||||
content = "Hello OpenWebUI".encode()
|
||||
b64 = base64.b64encode(content).decode()
|
||||
|
||||
# 2. 发送 JS 到前端执行
|
||||
js = f"""
|
||||
const blob = new Blob([atob('{b64}')], {{type: 'text/plain'}});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'hello.txt';
|
||||
a.click();
|
||||
"""
|
||||
await __event_call__({"type": "execute", "data": {"code": js}})
|
||||
```
|
||||
|
||||
### 3.2 Filter (过滤器)
|
||||
|
||||
**定位**:中间件,拦截并修改请求/响应。
|
||||
|
||||
* **`inlet`**: 请求前。用于注入上下文、修改模型参数。
|
||||
* **`outlet`**: 响应后。用于格式化输出、保存日志。
|
||||
* **`stream`**: 流式处理中。用于实时敏感词过滤。
|
||||
|
||||
**示例:注入环境变量**
|
||||
|
||||
```python
|
||||
async def inlet(self, body, __metadata__):
|
||||
vars = __metadata__.get("variables", {})
|
||||
context = f"当前时间: {vars.get('{{CURRENT_DATETIME}}')}"
|
||||
|
||||
# 注入到 System Prompt 或第一条消息
|
||||
if body.get("messages"):
|
||||
body["messages"][0]["content"] += f"\n\n{context}"
|
||||
return body
|
||||
```
|
||||
|
||||
### 3.3 Pipe (管道)
|
||||
|
||||
**定位**:自定义模型/代理。
|
||||
|
||||
**示例:简单的 OpenAI 代理**
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
class Pipe:
|
||||
def pipes(self):
|
||||
return [{"id": "my-gpt", "name": "My GPT Wrapper"}]
|
||||
|
||||
def pipe(self, body):
|
||||
# 可以在这里修改 body,例如强制添加 prompt
|
||||
headers = {"Authorization": f"Bearer {self.valves.API_KEY}"}
|
||||
r = requests.post("https://api.openai.com/v1/chat/completions", json=body, headers=headers, stream=True)
|
||||
return r.iter_lines()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 高级开发模式
|
||||
|
||||
### 4.1 Pipe 与 Filter 协同
|
||||
利用 `__request__.app.state` 在不同插件间共享数据。
|
||||
* **Pipe**: `__request__.app.state.search_results = [...]`
|
||||
* **Filter (Outlet)**: 读取 `search_results` 并将其格式化为引用链接附加到回复末尾。
|
||||
|
||||
### 4.2 异步后台任务
|
||||
不阻塞用户响应,在后台执行耗时操作(如生成总结、存库)。
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def outlet(self, body, __metadata__):
|
||||
asyncio.create_task(self.background_job(__metadata__["chat_id"]))
|
||||
return body
|
||||
|
||||
async def background_job(self, chat_id):
|
||||
# 执行耗时操作...
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 最佳实践与设计原则
|
||||
|
||||
### 5.1 命名与定位
|
||||
* **简短有力**:如 "闪记卡", "精读"。避免 "文本分析助手" 这种泛词。
|
||||
* **功能互补**:不要重复造轮子,明确你的插件解决了什么特定问题。
|
||||
|
||||
### 5.2 用户体验 (UX)
|
||||
* **反馈及时**:耗时操作前先发送 `notification` ("正在生成...")。
|
||||
* **视觉美观**:Action 输出 HTML 时,使用现代化的 CSS(圆角、阴影、渐变)。
|
||||
* **智能引导**:检测到文本过短时,提示用户"建议输入更多内容以获得更好结果"。
|
||||
|
||||
### 5.3 错误处理
|
||||
永远不要让插件静默失败。捕获异常并通过 `__event_emitter__` 告知用户。
|
||||
|
||||
```python
|
||||
try:
|
||||
# 业务逻辑
|
||||
except Exception as e:
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"处理失败: {str(e)}"}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 故障排查
|
||||
|
||||
* **HTML 不显示?** 确保包裹在 ` ```html ... ``` ` 代码块中。
|
||||
* **数据库报错?** 检查是否在 `async` 函数中直接调用了同步的 DB 方法,请使用 `asyncio.to_thread`。
|
||||
* **参数未生效?** 检查 `Valves` 定义是否正确,以及是否被 `UserValves` 覆盖。
|
||||
2236
docs/zh/从问一个AI到运营一支AI团队.md
Normal file
2236
docs/zh/从问一个AI到运营一支AI团队.md
Normal file
File diff suppressed because it is too large
Load Diff
124
plugins/README.md
Normal file
124
plugins/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Plugins
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
This directory contains three types of plugins for OpenWebUI:
|
||||
|
||||
- **Filters**: Process user input before sending to LLM
|
||||
- **Actions**: Trigger custom functionalities from chat
|
||||
- **Pipes**: Enhance LLM responses before displaying to user
|
||||
|
||||
## 📦 Plugin Types Overview
|
||||
|
||||
### 🔧 Filters (`/filters`)
|
||||
|
||||
Filters modify user input before it reaches the LLM. They are useful for:
|
||||
|
||||
- Input validation and normalization
|
||||
- Adding system prompts or context
|
||||
- Compressing long conversations
|
||||
- Preprocessing and formatting
|
||||
|
||||
[View Filters →](./filters/README.md)
|
||||
|
||||
### 🎬 Actions (`/actions`)
|
||||
|
||||
Actions are custom functionalities triggered from chat. They are useful for:
|
||||
|
||||
- Generating outputs (mind maps, charts, etc.)
|
||||
- Interacting with external APIs
|
||||
- Data transformations
|
||||
- File operations and exports
|
||||
- Complex workflows
|
||||
|
||||
[View Actions →](./actions/README.md)
|
||||
|
||||
### 📤 Pipes (`/pipes`)
|
||||
|
||||
Pipes process LLM responses after generation. They are useful for:
|
||||
|
||||
- Response formatting
|
||||
- Content enhancement
|
||||
- Translation and transformation
|
||||
- Response filtering
|
||||
- Integration with external services
|
||||
|
||||
[View Pipes →](./pipes/README.md)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installing Plugins
|
||||
|
||||
1. **Download** the desired plugin file (`.py`)
|
||||
2. **Open** OpenWebUI Admin Settings → Plugins
|
||||
3. **Select** the plugin type (Filters, Actions, or Pipes)
|
||||
4. **Upload** the file
|
||||
5. **Refresh** the page
|
||||
6. **Configure** in chat settings
|
||||
|
||||
### Using Plugins
|
||||
|
||||
- **Filters**: Automatically applied to all inputs when enabled
|
||||
- **Actions**: Selected manually from the actions menu during chat
|
||||
- **Pipes**: Automatically applied to all responses when enabled
|
||||
|
||||
## 📚 Plugin Documentation
|
||||
|
||||
Each plugin directory contains:
|
||||
|
||||
- Plugin code (`.py` files)
|
||||
- English documentation (`README.md`)
|
||||
- Chinese documentation (`README_CN.md`)
|
||||
- Configuration and usage guides
|
||||
|
||||
## 🛠️ Plugin Development
|
||||
|
||||
To create a new plugin:
|
||||
|
||||
1. Choose the plugin type (Filter, Action, or Pipe)
|
||||
2. Navigate to the corresponding directory
|
||||
3. Create a new folder for your plugin
|
||||
4. Write the plugin code with clear documentation
|
||||
5. Create `README.md` and `README_CN.md`
|
||||
6. Update the main README in that directory
|
||||
|
||||
### Plugin Structure Template
|
||||
|
||||
```python
|
||||
plugins/
|
||||
├── filters/
|
||||
│ ├── my_filter/
|
||||
│ │ ├── my_filter.py # Plugin code
|
||||
│ │ ├── my_filter_cn.py # Optional: Chinese version
|
||||
│ │ ├── README.md # Documentation
|
||||
│ │ └── README_CN.md # Chinese documentation
|
||||
│ └── README.md
|
||||
├── actions/
|
||||
│ ├── my_action/
|
||||
│ │ ├── my_action.py
|
||||
│ │ ├── README.md
|
||||
│ │ └── README_CN.md
|
||||
│ └── README.md
|
||||
└── pipes/
|
||||
├── my_pipe/
|
||||
│ ├── my_pipe.py
|
||||
│ ├── README.md
|
||||
│ └── README_CN.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 📋 Documentation Checklist
|
||||
|
||||
Each plugin should include:
|
||||
|
||||
- [ ] Clear feature description
|
||||
- [ ] Configuration parameters with defaults
|
||||
- [ ] Installation and setup instructions
|
||||
- [ ] Usage examples
|
||||
- [ ] Troubleshooting guide
|
||||
- [ ] Performance considerations
|
||||
- [ ] Version and author information
|
||||
|
||||
---
|
||||
|
||||
> **Note**: For detailed information about each plugin type, see the respective README files in each plugin type directory.
|
||||
124
plugins/README_CN.md
Normal file
124
plugins/README_CN.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Plugins(插件)
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
此目录包含 OpenWebUI 的三种类型的插件:
|
||||
|
||||
- **Filters(过滤器)**: 在将用户输入发送给 LLM 前进行处理
|
||||
- **Actions(动作)**: 从聊天中触发自定义功能
|
||||
- **Pipes(管道)**: 在显示给用户前增强 LLM 响应
|
||||
|
||||
## 📦 插件类型概览
|
||||
|
||||
### 🔧 Filters(过滤器)(`/filters`)
|
||||
|
||||
过滤器在用户输入到达 LLM 前修改它。用途包括:
|
||||
|
||||
- 输入验证和规范化
|
||||
- 添加系统提示或上下文
|
||||
- 压缩长对话
|
||||
- 预处理和格式化
|
||||
|
||||
[查看过滤器 →](./filters/README_CN.md)
|
||||
|
||||
### 🎬 Actions(动作)(`/actions`)
|
||||
|
||||
动作是从聊天中触发的自定义功能。用途包括:
|
||||
|
||||
- 生成输出(思维导图、图表等)
|
||||
- 与外部 API 交互
|
||||
- 数据转换
|
||||
- 文件操作和导出
|
||||
- 复杂工作流程
|
||||
|
||||
[查看动作 →](./actions/README_CN.md)
|
||||
|
||||
### 📤 Pipes(管道)(`/pipes`)
|
||||
|
||||
管道在 LLM 生成响应后处理它。用途包括:
|
||||
|
||||
- 响应格式化
|
||||
- 内容增强
|
||||
- 翻译和转换
|
||||
- 响应过滤
|
||||
- 与外部服务集成
|
||||
|
||||
[查看管道 →](./pipes/README_CN.md)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装插件
|
||||
|
||||
1. **下载**所需的插件文件(`.py`)
|
||||
2. **打开** OpenWebUI 管理员设置 → 插件(Plugins)
|
||||
3. **选择**插件类型(Filters、Actions 或 Pipes)
|
||||
4. **上传**文件
|
||||
5. **刷新**页面
|
||||
6. **配置**聊天设置中的参数
|
||||
|
||||
### 使用插件
|
||||
|
||||
- **Filters(过滤器)**: 启用后自动应用于所有输入
|
||||
- **Actions(动作)**: 在聊天时从动作菜单手动选择
|
||||
- **Pipes(管道)**: 启用后自动应用于所有响应
|
||||
|
||||
## 📚 插件文档
|
||||
|
||||
每个插件目录包含:
|
||||
|
||||
- 插件代码(`.py` 文件)
|
||||
- 英文文档(`README.md`)
|
||||
- 中文文档(`README_CN.md`)
|
||||
- 配置和使用指南
|
||||
|
||||
## 🛠️ 插件开发
|
||||
|
||||
要创建新插件:
|
||||
|
||||
1. 选择插件类型(Filter、Action 或 Pipe)
|
||||
2. 导航到对应的目录
|
||||
3. 为插件创建新文件夹
|
||||
4. 编写清晰记录的插件代码
|
||||
5. 创建 `README.md` 和 `README_CN.md`
|
||||
6. 更新该目录中的主 README
|
||||
|
||||
### 插件结构模板
|
||||
|
||||
```python
|
||||
plugins/
|
||||
├── filters/
|
||||
│ ├── my_filter/
|
||||
│ │ ├── my_filter.py # 插件代码
|
||||
│ │ ├── my_filter_cn.py # 可选:中文版本
|
||||
│ │ ├── README.md # 文档
|
||||
│ │ └── README_CN.md # 中文文档
|
||||
│ └── README.md
|
||||
├── actions/
|
||||
│ ├── my_action/
|
||||
│ │ ├── my_action.py
|
||||
│ │ ├── README.md
|
||||
│ │ └── README_CN.md
|
||||
│ └── README.md
|
||||
└── pipes/
|
||||
├── my_pipe/
|
||||
│ ├── my_pipe.py
|
||||
│ ├── README.md
|
||||
│ └── README_CN.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 📋 文档检查清单
|
||||
|
||||
每个插件应包含:
|
||||
|
||||
- [ ] 清晰的功能描述
|
||||
- [ ] 配置参数及默认值
|
||||
- [ ] 安装和设置说明
|
||||
- [ ] 使用示例
|
||||
- [ ] 故障排除指南
|
||||
- [ ] 性能考虑
|
||||
- [ ] 版本和作者信息
|
||||
|
||||
---
|
||||
|
||||
> **注意**:有关每种插件类型的详细信息,请参阅每个插件类型目录中的相应 README 文件。
|
||||
227
plugins/actions/README.md
Normal file
227
plugins/actions/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Actions (Action Plugins)
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
Action plugins allow you to define custom functionalities that can be triggered from chat. This directory contains various action plugins that can be used to extend OpenWebUI functionality.
|
||||
|
||||
## 📋 Action Plugins List
|
||||
|
||||
| Plugin Name | Description | Version | Documentation |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Smart Mind Map** | Intelligently analyzes text content and generates interactive mind maps | 0.7.2 | [English](./smart-mind-map/README.md) / [中文](./smart-mind-map/README_CN.md) |
|
||||
| **Flash Card (闪记卡)** | Quickly generates beautiful learning memory cards, perfect for studying and quick memorization | 0.2.0 | [English](./knowledge-card/README.md) / [中文](./knowledge-card/README_CN.md) |
|
||||
|
||||
## 🎯 What are Action Plugins?
|
||||
|
||||
Action plugins typically used for:
|
||||
|
||||
- Generating specific output formats (such as mind maps, charts, tables, etc.)
|
||||
- Interacting with external APIs or services
|
||||
- Performing data transformations and processing
|
||||
- Saving or exporting content to files
|
||||
- Creating interactive visualizations
|
||||
- Automating complex workflows
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installing an Action Plugin
|
||||
|
||||
1. Download the plugin file (`.py`) to your local machine
|
||||
2. Open OpenWebUI Admin Settings and find the "Plugins" section
|
||||
3. Select the "Actions" type
|
||||
4. Upload the downloaded file
|
||||
5. Refresh the page and enable the plugin in chat settings
|
||||
6. Use the plugin by selecting it from the available actions in chat
|
||||
|
||||
## 📖 Development Guide
|
||||
|
||||
### Adding a New Action Plugin
|
||||
|
||||
When adding a new action plugin, please follow these steps:
|
||||
|
||||
1. **Create Plugin Directory**: Create a new folder under `plugins/actions/` (e.g., `my_action/`)
|
||||
2. **Write Plugin Code**: Create a `.py` file with clear documentation of functionality
|
||||
3. **Write Documentation**:
|
||||
- Create `README.md` (English version)
|
||||
- Create `README_CN.md` (Chinese version)
|
||||
- Include: feature description, configuration, usage examples, and troubleshooting
|
||||
4. **Update This List**: Add your plugin to the table above
|
||||
|
||||
### Open WebUI Plugin Development Common Features
|
||||
|
||||
When developing Action plugins, you can use the following standard features provided by Open WebUI:
|
||||
|
||||
#### 1. **Plugin Metadata Definition**
|
||||
|
||||
```python
|
||||
"""
|
||||
title: Plugin Name
|
||||
icon_url: data:image/svg+xml;base64,... # Plugin icon (Base64 encoded SVG)
|
||||
version: 1.0.0
|
||||
description: Plugin functionality description
|
||||
"""
|
||||
```
|
||||
|
||||
#### 2. **Valves Configuration System**
|
||||
|
||||
Use Pydantic to define configurable parameters that users can adjust dynamically in the UI:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show status updates"
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
description="API key"
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. **Standard Action Class Structure**
|
||||
|
||||
```python
|
||||
class Action:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
# Plugin logic
|
||||
return body
|
||||
```
|
||||
|
||||
#### 4. **Getting User Information**
|
||||
|
||||
```python
|
||||
# Supports both dictionary and list formats
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
```
|
||||
|
||||
#### 5. **Event Emitter (event_emitter)**
|
||||
|
||||
**Sending notification messages:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info", # info/warning/error/success
|
||||
"content": "Message content"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Sending status updates:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Status description",
|
||||
"done": False, # True when completed
|
||||
"hidden": False # True to hide
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 6. **Calling Built-in LLM**
|
||||
|
||||
```python
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Get user object
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
# Build LLM request
|
||||
llm_payload = {
|
||||
"model": "model-id",
|
||||
"messages": [
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "User input"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# Call LLM
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
```
|
||||
|
||||
#### 7. **Handling Message Body**
|
||||
|
||||
```python
|
||||
# Read messages
|
||||
messages = body.get("messages")
|
||||
user_message = messages[-1]["content"]
|
||||
|
||||
# Modify messages
|
||||
body["messages"][-1]["content"] = f"{user_message}\n\nAdditional content"
|
||||
|
||||
# Return modified body
|
||||
return body
|
||||
```
|
||||
|
||||
#### 8. **Embedding HTML Content**
|
||||
|
||||
```python
|
||||
html_content = "<div>Interactive content</div>"
|
||||
html_embed_tag = f"```html\n{html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{text}\n\n{html_embed_tag}"
|
||||
```
|
||||
|
||||
#### 9. **Async Processing**
|
||||
|
||||
All plugin methods must be asynchronous:
|
||||
|
||||
```python
|
||||
async def action(...):
|
||||
await __event_emitter__(...)
|
||||
result = await some_async_function()
|
||||
return result
|
||||
```
|
||||
|
||||
#### 10. **Error Handling and Logging**
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
# Plugin logic
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {str(e)}", exc_info=True)
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"Operation failed: {str(e)}"}
|
||||
})
|
||||
```
|
||||
|
||||
### Development Best Practices
|
||||
|
||||
1. **Use Valves Configuration**: Allow users to customize plugin behavior
|
||||
2. **Provide Real-time Feedback**: Use event emitter to inform users of progress
|
||||
3. **Graceful Error Handling**: Catch exceptions and provide friendly messages
|
||||
4. **Support Multiple Languages**: Get language preference from `__user__`
|
||||
5. **Logging**: Record key operations and errors for debugging
|
||||
6. **Validate Input**: Check required parameters and data formats
|
||||
7. **Return Complete Body**: Ensure message flow is properly passed
|
||||
|
||||
---
|
||||
|
||||
> **Contributor Note**: To ensure project quality, please provide clear and complete documentation for each new plugin, including features, configuration, usage examples, and troubleshooting guides. Refer to the common features above when developing your plugins.
|
||||
226
plugins/actions/README_CN.md
Normal file
226
plugins/actions/README_CN.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Actions(动作插件)
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
动作插件(Actions)允许您定义可以从聊天中触发的自定义功能。此目录包含可用于扩展 OpenWebUI 功能的各种动作插件。
|
||||
|
||||
## 📋 动作插件列表
|
||||
|
||||
| 插件名称 | 描述 | 版本 | 文档 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **智绘心图** | 智能分析文本内容,生成交互式思维导图 | 0.7.2 | [中文](./smart-mind-map/README_CN.md) / [English](./smart-mind-map/README.md) |
|
||||
|
||||
## 🎯 什么是动作插件?
|
||||
|
||||
动作插件通常用于:
|
||||
|
||||
- 生成特定格式的输出(如思维导图、图表、表格等)
|
||||
- 与外部 API 或服务交互
|
||||
- 执行数据转换和处理
|
||||
- 保存或导出内容到文件
|
||||
- 创建交互式可视化
|
||||
- 自动化复杂工作流程
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装动作插件
|
||||
|
||||
1. 将插件文件(`.py`)下载到本地
|
||||
2. 在 OpenWebUI 管理员设置中,找到"Plugins"部分
|
||||
3. 选择"Actions"类型
|
||||
4. 上传下载的文件
|
||||
5. 刷新页面并在聊天设置中启用插件
|
||||
6. 在聊天中从可用动作中选择使用该插件
|
||||
|
||||
## 📖 开发指南
|
||||
|
||||
### 添加新动作插件
|
||||
|
||||
添加新动作插件时,请遵循以下步骤:
|
||||
|
||||
1. **创建插件目录**:在 `plugins/actions/` 下创建新文件夹(例如 `my_action/`)
|
||||
2. **编写插件代码**:创建 `.py` 文件,清晰记录功能说明
|
||||
3. **编写文档**:
|
||||
- 创建 `README.md`(英文版)
|
||||
- 创建 `README_CN.md`(中文版)
|
||||
- 包含:功能说明、配置方法、使用示例和故障排除
|
||||
4. **更新此列表**:在上述表格中添加您的插件
|
||||
|
||||
### Open WebUI 插件开发通用功能
|
||||
|
||||
开发 Action 插件时,可以使用以下 Open WebUI 提供的标准功能:
|
||||
|
||||
#### 1. **插件元数据定义**
|
||||
|
||||
```python
|
||||
"""
|
||||
title: 插件名称
|
||||
icon_url: data:image/svg+xml;base64,... # 插件图标(Base64编码的SVG)
|
||||
version: 1.0.0
|
||||
description: 插件功能描述
|
||||
"""
|
||||
```
|
||||
|
||||
#### 2. **Valves 配置系统**
|
||||
|
||||
使用 Pydantic 定义可配置参数,用户可在 UI 界面动态调整:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True,
|
||||
description="是否显示状态更新"
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
description="API密钥"
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. **标准 Action 类结构**
|
||||
|
||||
```python
|
||||
class Action:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
# 插件逻辑
|
||||
return body
|
||||
```
|
||||
|
||||
#### 4. **获取用户信息**
|
||||
|
||||
```python
|
||||
# 支持字典和列表两种格式
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
```
|
||||
|
||||
#### 5. **事件发射器 (event_emitter)**
|
||||
|
||||
**发送通知消息:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info", # info/warning/error/success
|
||||
"content": "消息内容"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**发送状态更新:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "状态描述",
|
||||
"done": False, # True表示完成
|
||||
"hidden": False # True表示隐藏
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 6. **调用内置 LLM**
|
||||
|
||||
```python
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# 获取用户对象
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
# 构建 LLM 请求
|
||||
llm_payload = {
|
||||
"model": "model-id",
|
||||
"messages": [
|
||||
{"role": "system", "content": "系统提示词"},
|
||||
{"role": "user", "content": "用户输入"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# 调用 LLM
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
```
|
||||
|
||||
#### 7. **处理消息体 (body)**
|
||||
|
||||
```python
|
||||
# 读取消息
|
||||
messages = body.get("messages")
|
||||
user_message = messages[-1]["content"]
|
||||
|
||||
# 修改消息
|
||||
body["messages"][-1]["content"] = f"{user_message}\n\n新增内容"
|
||||
|
||||
# 返回修改后的body
|
||||
return body
|
||||
```
|
||||
|
||||
#### 8. **嵌入 HTML 内容**
|
||||
|
||||
```python
|
||||
html_content = "<div>交互式内容</div>"
|
||||
html_embed_tag = f"```html\n{html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{text}\n\n{html_embed_tag}"
|
||||
```
|
||||
|
||||
#### 9. **异步处理**
|
||||
|
||||
所有插件方法必须是异步的:
|
||||
|
||||
```python
|
||||
async def action(...):
|
||||
await __event_emitter__(...)
|
||||
result = await some_async_function()
|
||||
return result
|
||||
```
|
||||
|
||||
#### 10. **错误处理和日志**
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
# 插件逻辑
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"错误: {str(e)}", exc_info=True)
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"操作失败: {str(e)}"}
|
||||
})
|
||||
```
|
||||
|
||||
### 开发最佳实践
|
||||
|
||||
1. **使用 Valves 配置**:让用户可以自定义插件行为
|
||||
2. **提供实时反馈**:使用事件发射器告知用户进度
|
||||
3. **优雅的错误处理**:捕获异常并给出友好提示
|
||||
4. **支持多语言**:从 `__user__` 获取语言偏好
|
||||
5. **日志记录**:记录关键操作和错误,便于调试
|
||||
6. **验证输入**:检查必需参数和数据格式
|
||||
7. **返回完整的 body**:确保消息流正确传递
|
||||
|
||||
---
|
||||
|
||||
> **贡献者注意**:为了确保项目质量,请为每个新增插件提供清晰完整的文档,包括功能说明、配置方法、使用示例和故障排除指南。参考上述通用功能开发您的插件。
|
||||
15
plugins/actions/export_to_excel/README.md
Normal file
15
plugins/actions/export_to_excel/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Export to Excel
|
||||
|
||||
This plugin allows you to export your chat history to an Excel (.xlsx) file directly from the chat interface.
|
||||
|
||||
## Features
|
||||
|
||||
- **One-Click Export**: Adds an "Export to Excel" button to the chat.
|
||||
- **Automatic Header Extraction**: Intelligently identifies table headers from the chat content.
|
||||
- **Multi-Table Support**: Handles multiple tables within a single chat session.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. In any chat, click the "Export to Excel" button.
|
||||
3. The file will be automatically downloaded to your device.
|
||||
15
plugins/actions/export_to_excel/README_CN.md
Normal file
15
plugins/actions/export_to_excel/README_CN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 导出为 Excel
|
||||
|
||||
此插件允许你直接从聊天界面将对话历史导出为 Excel (.xlsx) 文件。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **一键导出**:在聊天界面添加“导出为 Excel”按钮。
|
||||
- **自动表头提取**:智能识别聊天内容中的表格标题。
|
||||
- **多表支持**:支持处理单次对话中的多个表格。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 在任意对话中,点击“导出为 Excel”按钮。
|
||||
3. 文件将自动下载到你的设备。
|
||||
804
plugins/actions/export_to_excel/export_to_excel.py
Normal file
804
plugins/actions/export_to_excel/export_to_excel.py
Normal file
@@ -0,0 +1,804 @@
|
||||
"""
|
||||
title: 导出到Excel
|
||||
author: Fu-Jie
|
||||
description: 从最后一条AI回答消息中提取Markdown表格到Excel文件,并在浏览器中触发下载。支持多表并自动根据标题命名
|
||||
icon_url: data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPgo8c3ZnIHdpZHRoPSI4MDBweCIgaGVpZ2h0PSI4MDBweCIgdmlld0JveD0iMCAtMS4yNyAxMTAuMDM3IDExMC4wMzciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTU3LjU1IDBoNy40MjV2MTBjMTIuNTEzIDAgMjUuMDI1LjAyNSAzNy41MzctLjAzOCAyLjExMy4wODcgNC40MzgtLjA2MiA2LjI3NSAxLjIgMS4yODcgMS44NSAxLjEzOCA0LjIgMS4yMjUgNi4zMjUtLjA2MiAyMS43LS4wMzcgNDMuMzg4LS4wMjQgNjUuMDc1LS4wNjIgMy42MzguMzM3IDcuMzUtLjQyNSAxMC45MzgtLjUgMi42LTMuNjI1IDIuNjYyLTUuNzEzIDIuNzUtMTIuOTUuMDM3LTI1LjkxMi0uMDI1LTM4Ljg3NSAwdjExLjI1aC03Ljc2M2MtMTkuMDUtMy40NjMtMzguMTM4LTYuNjYyLTU3LjIxMi0xMFYxMC4wMTNDMTkuMTg4IDYuNjc1IDM4LjM3NSAzLjM4OCA1Ny41NSAweiIgZmlsbD0iIzIwNzI0NSIvPjxwYXRoIGQ9Ik02NC45NzUgMTMuNzVoNDEuMjVWOTIuNWgtNDEuMjVWODVoMTB2LTguNzVoLTEwdi01aDEwVjYyLjVoLTEwdi01aDEwdi04Ljc1aC0xMHYtNWgxMFYzNWgtMTB2LTVoMTB2LTguNzVoLTEwdi03LjV6IiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTc5Ljk3NSAyMS4yNWgxNy41VjMwaC0xNy41di04Ljc1eiIgZmlsbD0iIzIwNzI0NSIvPjxwYXRoIGQ9Ik0zNy4wMjUgMzIuOTYyYzIuODI1LS4yIDUuNjYzLS4zNzUgOC41LS41MTJhMjYwNy4zNDQgMjYwNy4zNDQgMCAwIDEtMTAuMDg3IDIwLjQ4N2MzLjQzOCA3IDYuOTQ5IDEzLjk1IDEwLjM5OSAyMC45NSBhNzE2LjI4IDcxNi4yOCAwIDAgMS05LjAyNC0uNTc1Yy0yLjEyNS01LjIxMy00LjcxMy0xMC4yNS02LjIzOC0xNS43Yy0xLjY5OSA1LjA3NS00LjEyNSA5Ljg2Mi02LjA3NCAxNC44MzgtMi43MzgtLjAzOC01LjQ3Ni0uMTUtOC4yMTMtLjI2M0MxOS41IDY1LjkgMjIuNiA1OS41NjIgMjUuOTEyIDUzLjMxMmMtMi44MTItNi40MzgtNS45LTEyLjc1LTguOC0xOS4xNSAyLjc1LS4xNjMgNS41LS4zMjUgOC4yNS0uNDc1IDEuODYyIDQuODg4IDMuODk5IDkuNzEyIDUuNDM4IDE0LjcyNSAxLjY0OS01LjMxMiA0LjExMi0xMC4zMTIgNi4yMjUtMTUuNDV6IiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTc5Ljk3NSAzNWgxNy41djguNzVoLTE3LjVWMzV6TTc5Ljk3NSA0OC43NWgxNy41djguNzVoLTE3LjV2LTguNzV6TTc5Ljk3NSA2Mi41aDE3LjV2OC43NWgtMTcuNVY2Mi41ek03OS45NzUgNzYuMjVoMTcuNVY4NWgtMTcuNXYtOC43NXoiIGZpbGw9IiMyMDcyNDUiLz48L3N2Zz4=
|
||||
version: 0.1.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import re
|
||||
import base64
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional, Callable, Awaitable, Any, List, Dict
|
||||
import datetime
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Action:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def _send_notification(self, emitter: Callable, type: str, content: str):
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": type, "content": content}}
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
):
|
||||
print(f"action:{__name__}")
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
if __event_emitter__:
|
||||
last_assistant_message = body["messages"][-1]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "正在保存到文件...", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
message_content = last_assistant_message["content"]
|
||||
tables = self.extract_tables_from_message(message_content)
|
||||
|
||||
if not tables:
|
||||
raise HTTPException(status_code=400, detail="未找到任何表格。")
|
||||
|
||||
# 获取动态文件名和sheet名称
|
||||
workbook_name, sheet_names = self.generate_names_from_content(
|
||||
message_content, tables
|
||||
)
|
||||
|
||||
# 使用优化后的文件名生成逻辑
|
||||
current_datetime = datetime.datetime.now()
|
||||
formatted_date = current_datetime.strftime("%Y%m%d")
|
||||
|
||||
# 如果没找到标题则使用 user_yyyymmdd 格式
|
||||
if not workbook_name:
|
||||
workbook_name = f"{user_name}_{formatted_date}"
|
||||
|
||||
filename = f"{workbook_name}.xlsx"
|
||||
excel_file_path = os.path.join(
|
||||
"app", "backend", "data", "temp", filename
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(excel_file_path), exist_ok=True)
|
||||
|
||||
# 保存表格到Excel(使用符合中国规范的格式化功能)
|
||||
self.save_tables_to_excel_enhanced(tables, excel_file_path, sheet_names)
|
||||
|
||||
# 触发文件下载
|
||||
if __event_call__:
|
||||
with open(excel_file_path, "rb") as file:
|
||||
file_content = file.read()
|
||||
base64_blob = base64.b64encode(file_content).decode("utf-8")
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
try {{
|
||||
const base64Data = "{base64_blob}";
|
||||
const binaryData = atob(base64Data);
|
||||
const arrayBuffer = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {{
|
||||
arrayBuffer[i] = binaryData.charCodeAt(i);
|
||||
}}
|
||||
const blob = new Blob([arrayBuffer], {{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }});
|
||||
const filename = "{filename}";
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}} catch (error) {{
|
||||
console.error('触发下载时出错:', error);
|
||||
}}
|
||||
"""
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "输出已保存", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
if os.path.exists(excel_file_path):
|
||||
os.remove(excel_file_path)
|
||||
|
||||
return {"message": "下载事件已触发"}
|
||||
|
||||
except HTTPException as e:
|
||||
print(f"Error processing tables: {str(e.detail)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {e.detail}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
print(f"Error processing tables: {str(e)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {str(e)}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
|
||||
def extract_tables_from_message(self, message: str) -> List[Dict]:
|
||||
"""
|
||||
从消息文本中提取Markdown表格及位置信息
|
||||
返回结构: [{
|
||||
"data": 表格数据,
|
||||
"start_line": 起始行号,
|
||||
"end_line": 结束行号
|
||||
}]
|
||||
"""
|
||||
table_row_pattern = r"^\s*\|.*\|.*\s*$"
|
||||
rows = message.split("\n")
|
||||
tables = []
|
||||
current_table = []
|
||||
start_line = None
|
||||
current_line = 0
|
||||
|
||||
for row in rows:
|
||||
current_line += 1
|
||||
if re.search(table_row_pattern, row):
|
||||
if start_line is None:
|
||||
start_line = current_line # 记录表格起始行
|
||||
|
||||
# 处理表格行
|
||||
cells = [cell.strip() for cell in row.strip().strip("|").split("|")]
|
||||
|
||||
# 跳过分隔行
|
||||
is_separator_row = all(re.fullmatch(r"[:\-]+", cell) for cell in cells)
|
||||
if not is_separator_row:
|
||||
current_table.append(cells)
|
||||
elif current_table:
|
||||
# 表格结束
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line - 1,
|
||||
}
|
||||
)
|
||||
current_table = []
|
||||
start_line = None
|
||||
|
||||
# 处理最后一个表格
|
||||
if current_table:
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line,
|
||||
}
|
||||
)
|
||||
|
||||
return tables
|
||||
|
||||
def generate_names_from_content(self, content: str, tables: List[Dict]) -> tuple:
|
||||
"""
|
||||
根据内容生成工作簿名称和sheet名称
|
||||
- 忽略非空段落,只使用 markdown 标题 (h1-h6)。
|
||||
- 单表格: 使用最近的标题作为工作簿和工作表名。
|
||||
- 多表格: 使用文档第一个标题作为工作簿名,各表格最近的标题作为工作表名。
|
||||
- 默认命名:
|
||||
- 工作簿: 在主流程中处理 (user_yyyymmdd.xlsx)。
|
||||
- 工作表: 表1, 表2, ...
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
workbook_name = ""
|
||||
sheet_names = []
|
||||
all_headers = []
|
||||
|
||||
# 1. 查找文档中所有 h1-h6 标题及其位置
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^#{1,6}\s+", line):
|
||||
all_headers.append(
|
||||
{"text": re.sub(r"^#{1,6}\s+", "", line).strip(), "line_num": i}
|
||||
)
|
||||
|
||||
# 2. 为每个表格生成 sheet 名称
|
||||
for i, table in enumerate(tables):
|
||||
table_start_line = table["start_line"] - 1 # 转换为 0-based 索引
|
||||
closest_header_text = None
|
||||
|
||||
# 查找当前表格上方最近的标题
|
||||
candidate_headers = [
|
||||
h for h in all_headers if h["line_num"] < table_start_line
|
||||
]
|
||||
if candidate_headers:
|
||||
# 找到候选标题中行号最大的,即为最接近的
|
||||
closest_header = max(candidate_headers, key=lambda x: x["line_num"])
|
||||
closest_header_text = closest_header["text"]
|
||||
|
||||
if closest_header_text:
|
||||
# 清理并添加找到的标题
|
||||
sheet_names.append(self.clean_sheet_name(closest_header_text))
|
||||
else:
|
||||
# 如果找不到标题,使用默认名称 "表{i+1}"
|
||||
sheet_names.append(f"表{i+1}")
|
||||
|
||||
# 3. 根据表格数量确定工作簿名称
|
||||
if len(tables) == 1:
|
||||
# 单个表格: 使用其工作表名作为工作簿名 (前提是该名称不是默认的 "表1")
|
||||
if sheet_names[0] != "表1":
|
||||
workbook_name = sheet_names[0]
|
||||
elif len(tables) > 1:
|
||||
# 多个表格: 使用文档中的第一个标题作为工作簿名
|
||||
if all_headers:
|
||||
# 找到所有标题中行号最小的,即为第一个标题
|
||||
first_header = min(all_headers, key=lambda x: x["line_num"])
|
||||
workbook_name = first_header["text"]
|
||||
|
||||
# 4. 清理工作簿名称 (如果为空,主流程会使用默认名称)
|
||||
workbook_name = self.clean_filename(workbook_name) if workbook_name else ""
|
||||
|
||||
return workbook_name, sheet_names
|
||||
|
||||
def clean_filename(self, name: str) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()
|
||||
|
||||
def clean_sheet_name(self, name: str) -> str:
|
||||
"""清理sheet名称(限制31字符,去除非法字符)"""
|
||||
name = re.sub(r"[\\/*?[\]:]", "", name).strip()
|
||||
return name[:31] if len(name) > 31 else name
|
||||
|
||||
# ======================== 符合中国规范的格式化功能 ========================
|
||||
|
||||
def calculate_text_width(self, text: str) -> float:
|
||||
"""
|
||||
计算文本显示宽度,考虑中英文字符差异
|
||||
中文字符按2个单位计算,英文字符按1个单位计算
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
width = 0
|
||||
for char in str(text):
|
||||
# 判断是否为中文字符(包括中文标点)
|
||||
if "\u4e00" <= char <= "\u9fff" or "\u3000" <= char <= "\u303f":
|
||||
width += 2 # 中文字符占2个单位宽度
|
||||
else:
|
||||
width += 1 # 英文字符占1个单位宽度
|
||||
|
||||
return width
|
||||
|
||||
def calculate_text_height(self, text: str, max_width: int = 50) -> int:
|
||||
"""
|
||||
计算文本显示所需的行数
|
||||
根据换行符和文本长度计算
|
||||
"""
|
||||
if not text:
|
||||
return 1
|
||||
|
||||
text = str(text)
|
||||
# 计算换行符导致的行数
|
||||
explicit_lines = text.count("\n") + 1
|
||||
|
||||
# 计算因文本长度超出而需要的额外行数
|
||||
text_width = self.calculate_text_width(text.replace("\n", ""))
|
||||
wrapped_lines = max(
|
||||
1, int(text_width / max_width) + (1 if text_width % max_width > 0 else 0)
|
||||
)
|
||||
|
||||
return max(explicit_lines, wrapped_lines)
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def determine_content_type(self, header: str, values: list) -> str:
|
||||
"""
|
||||
根据表头和内容智能判断数据类型,符合中国官方表格规范
|
||||
返回: 'number', 'date', 'sequence', 'text'
|
||||
"""
|
||||
header_lower = str(header).lower().strip()
|
||||
|
||||
# 检查表头关键词
|
||||
number_keywords = [
|
||||
"数量",
|
||||
"金额",
|
||||
"价格",
|
||||
"费用",
|
||||
"成本",
|
||||
"收入",
|
||||
"支出",
|
||||
"总计",
|
||||
"小计",
|
||||
"百分比",
|
||||
"%",
|
||||
"比例",
|
||||
"率",
|
||||
"数值",
|
||||
"分数",
|
||||
"成绩",
|
||||
"得分",
|
||||
]
|
||||
date_keywords = ["日期", "时间", "年份", "月份", "时刻", "date", "time"]
|
||||
sequence_keywords = [
|
||||
"序号",
|
||||
"编号",
|
||||
"号码",
|
||||
"排序",
|
||||
"次序",
|
||||
"顺序",
|
||||
"id",
|
||||
"编码",
|
||||
]
|
||||
|
||||
# 检查表头
|
||||
for keyword in number_keywords:
|
||||
if keyword in header_lower:
|
||||
return "number"
|
||||
|
||||
for keyword in date_keywords:
|
||||
if keyword in header_lower:
|
||||
return "date"
|
||||
|
||||
for keyword in sequence_keywords:
|
||||
if keyword in header_lower:
|
||||
return "sequence"
|
||||
|
||||
# 检查数据内容
|
||||
if not values:
|
||||
return "text"
|
||||
|
||||
sample_values = [
|
||||
str(v).strip() for v in values[:10] if str(v).strip()
|
||||
] # 取前10个非空值作为样本
|
||||
if not sample_values:
|
||||
return "text"
|
||||
|
||||
numeric_count = 0
|
||||
date_count = 0
|
||||
sequence_count = 0
|
||||
|
||||
for value in sample_values:
|
||||
# 检查是否为数字
|
||||
try:
|
||||
float(
|
||||
value.replace(",", "")
|
||||
.replace(",", "")
|
||||
.replace("%", "")
|
||||
.replace("%", "")
|
||||
)
|
||||
numeric_count += 1
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 检查是否为日期格式
|
||||
date_patterns = [
|
||||
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?",
|
||||
r"\d{1,2}[-/]\d{1,2}[-/]\d{4}",
|
||||
r"\d{4}\d{2}\d{2}",
|
||||
]
|
||||
for pattern in date_patterns:
|
||||
if re.match(pattern, value):
|
||||
date_count += 1
|
||||
break
|
||||
|
||||
# 检查是否为序号格式
|
||||
if (
|
||||
re.match(r"^\d+$", value) and len(value) <= 4
|
||||
): # 纯数字且不超过4位,可能是序号
|
||||
sequence_count += 1
|
||||
|
||||
total_count = len(sample_values)
|
||||
|
||||
# 根据比例判断类型
|
||||
if numeric_count / total_count >= 0.7:
|
||||
return "number"
|
||||
elif date_count / total_count >= 0.7:
|
||||
return "date"
|
||||
elif sequence_count / total_count >= 0.8 and sequence_count > 2:
|
||||
return "sequence"
|
||||
else:
|
||||
return "text"
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def save_tables_to_excel_enhanced(
|
||||
self, tables: List[Dict], file_path: str, sheet_names: List[str]
|
||||
):
|
||||
"""
|
||||
符合中国官方表格规范的Excel保存功能
|
||||
"""
|
||||
try:
|
||||
with pd.ExcelWriter(file_path, engine="xlsxwriter") as writer:
|
||||
workbook = writer.book
|
||||
|
||||
# 定义表头样式 - 居中对齐(符合中国规范)
|
||||
header_format = workbook.add_format(
|
||||
{
|
||||
"bold": True,
|
||||
"font_size": 12,
|
||||
"font_color": "white",
|
||||
"bg_color": "#00abbd",
|
||||
"border": 1,
|
||||
"align": "center", # 表头居中
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 文本单元格样式 - 左对齐
|
||||
text_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "left", # 文本左对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 数值单元格样式 - 右对齐
|
||||
number_format = workbook.add_format(
|
||||
{"border": 1, "align": "right", "valign": "vcenter"} # 数值右对齐
|
||||
)
|
||||
|
||||
# 整数格式 - 右对齐
|
||||
integer_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"border": 1,
|
||||
"align": "right", # 整数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 小数格式 - 右对齐
|
||||
decimal_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"border": 1,
|
||||
"align": "right", # 小数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 日期格式 - 居中对齐
|
||||
date_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 日期居中对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 序号格式 - 居中对齐
|
||||
sequence_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 序号居中对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
try:
|
||||
table_data = table["data"]
|
||||
if not table_data or len(table_data) < 1:
|
||||
print(f"Skipping empty table at index {i}")
|
||||
continue
|
||||
|
||||
print(f"Processing table {i+1} with {len(table_data)} rows")
|
||||
|
||||
# 获取sheet名称
|
||||
sheet_name = (
|
||||
sheet_names[i] if i < len(sheet_names) else f"表{i+1}"
|
||||
)
|
||||
|
||||
# 创建DataFrame
|
||||
headers = [
|
||||
str(cell).strip()
|
||||
for cell in table_data[0]
|
||||
if str(cell).strip()
|
||||
]
|
||||
if not headers:
|
||||
print(f"Warning: No valid headers found for table {i+1}")
|
||||
headers = [f"列{j+1}" for j in range(len(table_data[0]))]
|
||||
|
||||
data_rows = []
|
||||
if len(table_data) > 1:
|
||||
max_cols = len(headers)
|
||||
for row in table_data[1:]:
|
||||
processed_row = []
|
||||
for j in range(max_cols):
|
||||
if j < len(row):
|
||||
processed_row.append(str(row[j]))
|
||||
else:
|
||||
processed_row.append("")
|
||||
data_rows.append(processed_row)
|
||||
df = pd.DataFrame(data_rows, columns=headers)
|
||||
else:
|
||||
df = pd.DataFrame(columns=headers)
|
||||
|
||||
print(f"DataFrame created with columns: {list(df.columns)}")
|
||||
|
||||
# 修复pandas FutureWarning - 使用try-except替代errors='ignore'
|
||||
for col in df.columns:
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 先写入数据(不包含表头)
|
||||
df.to_excel(
|
||||
writer,
|
||||
sheet_name=sheet_name,
|
||||
index=False,
|
||||
header=False,
|
||||
startrow=1,
|
||||
)
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# 应用符合中国规范的格式化
|
||||
self.apply_chinese_standard_formatting(
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing table {i+1}: {str(e)}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving Excel file: {str(e)}")
|
||||
raise
|
||||
|
||||
def apply_chinese_standard_formatting(
|
||||
self,
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
):
|
||||
"""
|
||||
应用符合中国官方表格规范的格式化
|
||||
- 表头: 居中对齐
|
||||
- 数值: 右对齐
|
||||
- 文本: 左对齐
|
||||
- 日期: 居中对齐
|
||||
- 序号: 居中对齐
|
||||
"""
|
||||
try:
|
||||
# 1. 写入表头(居中对齐)
|
||||
print(f"Writing headers with Chinese standard alignment: {headers}")
|
||||
for col_idx, header in enumerate(headers):
|
||||
if header and str(header).strip():
|
||||
worksheet.write(0, col_idx, str(header).strip(), header_format)
|
||||
else:
|
||||
default_header = f"列{col_idx+1}"
|
||||
worksheet.write(0, col_idx, default_header, header_format)
|
||||
|
||||
# 2. 分析每列的数据类型并应用相应格式
|
||||
column_types = {}
|
||||
for col_idx, column in enumerate(headers):
|
||||
if col_idx < len(df.columns):
|
||||
column_values = df.iloc[:, col_idx].tolist()
|
||||
column_types[col_idx] = self.determine_content_type(
|
||||
column, column_values
|
||||
)
|
||||
print(
|
||||
f"Column '{column}' determined as type: {column_types[col_idx]}"
|
||||
)
|
||||
else:
|
||||
column_types[col_idx] = "text"
|
||||
|
||||
# 3. 写入并格式化数据(根据类型使用不同对齐方式)
|
||||
for row_idx, row in df.iterrows():
|
||||
for col_idx, value in enumerate(row):
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
|
||||
# 根据内容类型选择格式
|
||||
if content_type == "number":
|
||||
# 数值类型 - 右对齐
|
||||
if pd.api.types.is_numeric_dtype(df.iloc[:, col_idx]):
|
||||
if pd.api.types.is_integer_dtype(df.iloc[:, col_idx]):
|
||||
current_format = integer_format
|
||||
else:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
if numeric_value.is_integer():
|
||||
current_format = integer_format
|
||||
value = int(numeric_value)
|
||||
else:
|
||||
current_format = decimal_format
|
||||
except (ValueError, TypeError):
|
||||
current_format = decimal_format
|
||||
else:
|
||||
current_format = number_format
|
||||
|
||||
elif content_type == "date":
|
||||
# 日期类型 - 居中对齐
|
||||
current_format = date_format
|
||||
|
||||
elif content_type == "sequence":
|
||||
# 序号类型 - 居中对齐
|
||||
current_format = sequence_format
|
||||
|
||||
else:
|
||||
# 文本类型 - 左对齐
|
||||
current_format = text_format
|
||||
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
|
||||
# 4. 自动调整列宽
|
||||
for col_idx, column in enumerate(headers):
|
||||
col_letter = self.get_column_letter(col_idx)
|
||||
|
||||
# 计算表头宽度
|
||||
header_width = self.calculate_text_width(str(column))
|
||||
|
||||
# 计算数据列的最大宽度
|
||||
max_data_width = 0
|
||||
if not df.empty and col_idx < len(df.columns):
|
||||
for value in df.iloc[:, col_idx]:
|
||||
value_width = self.calculate_text_width(str(value))
|
||||
max_data_width = max(max_data_width, value_width)
|
||||
|
||||
# 基础宽度:取表头和数据的最大宽度
|
||||
base_width = max(header_width, max_data_width)
|
||||
|
||||
# 根据内容类型调整宽度
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
if content_type == "sequence":
|
||||
# 序号列通常比较窄
|
||||
optimal_width = max(8, min(15, base_width + 2))
|
||||
elif content_type == "number":
|
||||
# 数值列需要额外空间显示数字
|
||||
optimal_width = max(12, min(25, base_width + 3))
|
||||
elif content_type == "date":
|
||||
# 日期列需要固定宽度
|
||||
optimal_width = max(15, min(20, base_width + 2))
|
||||
else:
|
||||
# 文本列根据内容调整
|
||||
if base_width <= 10:
|
||||
optimal_width = base_width + 3
|
||||
elif base_width <= 20:
|
||||
optimal_width = base_width + 4
|
||||
else:
|
||||
optimal_width = base_width + 5
|
||||
optimal_width = max(10, min(60, optimal_width))
|
||||
|
||||
worksheet.set_column(f"{col_letter}:{col_letter}", optimal_width)
|
||||
|
||||
# 5. 自动调整行高
|
||||
# 设置表头行高为35点
|
||||
worksheet.set_row(0, 35)
|
||||
|
||||
# 设置数据行行高
|
||||
for row_idx, row in df.iterrows():
|
||||
max_row_height = 20 # 中国表格规范建议的最小行高
|
||||
|
||||
for col_idx, value in enumerate(row):
|
||||
if col_idx < len(headers):
|
||||
col_width = min(
|
||||
60,
|
||||
max(
|
||||
10, self.calculate_text_width(str(headers[col_idx])) + 5
|
||||
),
|
||||
)
|
||||
else:
|
||||
col_width = 15
|
||||
|
||||
cell_lines = self.calculate_text_height(str(value), col_width)
|
||||
cell_height = cell_lines * 20 # 每行20点高度,符合中国规范
|
||||
|
||||
max_row_height = max(max_row_height, cell_height)
|
||||
|
||||
final_height = min(120, max_row_height)
|
||||
worksheet.set_row(row_idx + 1, final_height)
|
||||
|
||||
print(f"Successfully applied Chinese standard formatting")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to apply Chinese standard formatting: {str(e)}")
|
||||
# 降级到基础格式化
|
||||
self.apply_basic_formatting_fallback(worksheet, df)
|
||||
|
||||
def apply_basic_formatting_fallback(self, worksheet, df):
|
||||
"""
|
||||
基础格式化降级方案
|
||||
"""
|
||||
try:
|
||||
# 基础列宽调整
|
||||
for i, column in enumerate(df.columns):
|
||||
column_width = (
|
||||
max(
|
||||
len(str(column)),
|
||||
(df[column].astype(str).map(len).max() if not df.empty else 0),
|
||||
)
|
||||
+ 2
|
||||
)
|
||||
|
||||
col_letter = self.get_column_letter(i)
|
||||
worksheet.set_column(
|
||||
f"{col_letter}:{col_letter}", min(60, max(10, column_width))
|
||||
)
|
||||
|
||||
print("Applied basic formatting fallback")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Even basic formatting failed: {str(e)}")
|
||||
806
plugins/actions/export_to_excel/export_to_excel_cn.py
Normal file
806
plugins/actions/export_to_excel/export_to_excel_cn.py
Normal file
@@ -0,0 +1,806 @@
|
||||
"""
|
||||
title: 导出为 Excel
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.3.3
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNCAyaDZhMiAyIDAgMCAxIDIgMnYxNmEyIDIgMCAwIDEtMiAyaC02YTIgMiAwIDAgMS0yLTJ2LTVhMiAyIDAgMCAxLTItMnYtNSIvPjxwb2x5bGluZSBwb2ludHM9IjE0IDIgMTQgOCAyMCA4Ii8+PHBhdGggZD0iTTE2IDEzdjgiLz48cGF0aCBkPSJNOCAxM3Y4Ii8+PHBhdGggZD0iTTEyIDEzdjgiLz48cGF0aCBkPSJNMTYgMTdoLTgiLz48cGF0aCBkPSJNMTYgMjFoLTgiLz48cGF0aCBkPSJNMTYgMTNoLTgiLz48L3N2Zz4=
|
||||
description: 将当前对话历史导出为 Excel (.xlsx) 文件,支持自动提取表头。
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import re
|
||||
import base64
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional, Callable, Awaitable, Any, List, Dict
|
||||
import datetime
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Action:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def _send_notification(self, emitter: Callable, type: str, content: str):
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": type, "content": content}}
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
):
|
||||
print(f"action:{__name__}")
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
if __event_emitter__:
|
||||
last_assistant_message = body["messages"][-1]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "正在保存到文件...", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
message_content = last_assistant_message["content"]
|
||||
tables = self.extract_tables_from_message(message_content)
|
||||
|
||||
if not tables:
|
||||
raise HTTPException(status_code=400, detail="未找到任何表格。")
|
||||
|
||||
# 获取动态文件名和sheet名称
|
||||
workbook_name, sheet_names = self.generate_names_from_content(
|
||||
message_content, tables
|
||||
)
|
||||
|
||||
# 使用优化后的文件名生成逻辑
|
||||
current_datetime = datetime.datetime.now()
|
||||
formatted_date = current_datetime.strftime("%Y%m%d")
|
||||
|
||||
# 如果没找到标题则使用 user_yyyymmdd 格式
|
||||
if not workbook_name:
|
||||
workbook_name = f"{user_name}_{formatted_date}"
|
||||
|
||||
filename = f"{workbook_name}.xlsx"
|
||||
excel_file_path = os.path.join(
|
||||
"app", "backend", "data", "temp", filename
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(excel_file_path), exist_ok=True)
|
||||
|
||||
# 保存表格到Excel(使用符合中国规范的格式化功能)
|
||||
self.save_tables_to_excel_enhanced(tables, excel_file_path, sheet_names)
|
||||
|
||||
# 触发文件下载
|
||||
if __event_call__:
|
||||
with open(excel_file_path, "rb") as file:
|
||||
file_content = file.read()
|
||||
base64_blob = base64.b64encode(file_content).decode("utf-8")
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
try {{
|
||||
const base64Data = "{base64_blob}";
|
||||
const binaryData = atob(base64Data);
|
||||
const arrayBuffer = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {{
|
||||
arrayBuffer[i] = binaryData.charCodeAt(i);
|
||||
}}
|
||||
const blob = new Blob([arrayBuffer], {{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }});
|
||||
const filename = "{filename}";
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}} catch (error) {{
|
||||
console.error('触发下载时出错:', error);
|
||||
}}
|
||||
"""
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "输出已保存", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
if os.path.exists(excel_file_path):
|
||||
os.remove(excel_file_path)
|
||||
|
||||
return {"message": "下载事件已触发"}
|
||||
|
||||
except HTTPException as e:
|
||||
print(f"Error processing tables: {str(e.detail)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {e.detail}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
print(f"Error processing tables: {str(e)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {str(e)}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
|
||||
def extract_tables_from_message(self, message: str) -> List[Dict]:
|
||||
"""
|
||||
从消息文本中提取Markdown表格及位置信息
|
||||
返回结构: [{
|
||||
"data": 表格数据,
|
||||
"start_line": 起始行号,
|
||||
"end_line": 结束行号
|
||||
}]
|
||||
"""
|
||||
table_row_pattern = r"^\s*\|.*\|.*\s*$"
|
||||
rows = message.split("\n")
|
||||
tables = []
|
||||
current_table = []
|
||||
start_line = None
|
||||
current_line = 0
|
||||
|
||||
for row in rows:
|
||||
current_line += 1
|
||||
if re.search(table_row_pattern, row):
|
||||
if start_line is None:
|
||||
start_line = current_line # 记录表格起始行
|
||||
|
||||
# 处理表格行
|
||||
cells = [cell.strip() for cell in row.strip().strip("|").split("|")]
|
||||
|
||||
# 跳过分隔行
|
||||
is_separator_row = all(re.fullmatch(r"[:\-]+", cell) for cell in cells)
|
||||
if not is_separator_row:
|
||||
current_table.append(cells)
|
||||
elif current_table:
|
||||
# 表格结束
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line - 1,
|
||||
}
|
||||
)
|
||||
current_table = []
|
||||
start_line = None
|
||||
|
||||
# 处理最后一个表格
|
||||
if current_table:
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line,
|
||||
}
|
||||
)
|
||||
|
||||
return tables
|
||||
|
||||
def generate_names_from_content(self, content: str, tables: List[Dict]) -> tuple:
|
||||
"""
|
||||
根据内容生成工作簿名称和sheet名称
|
||||
- 忽略非空段落,只使用 markdown 标题 (h1-h6)。
|
||||
- 单表格: 使用最近的标题作为工作簿和工作表名。
|
||||
- 多表格: 使用文档第一个标题作为工作簿名,各表格最近的标题作为工作表名。
|
||||
- 默认命名:
|
||||
- 工作簿: 在主流程中处理 (user_yyyymmdd.xlsx)。
|
||||
- 工作表: 表1, 表2, ...
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
workbook_name = ""
|
||||
sheet_names = []
|
||||
all_headers = []
|
||||
|
||||
# 1. 查找文档中所有 h1-h6 标题及其位置
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^#{1,6}\s+", line):
|
||||
all_headers.append(
|
||||
{"text": re.sub(r"^#{1,6}\s+", "", line).strip(), "line_num": i}
|
||||
)
|
||||
|
||||
# 2. 为每个表格生成 sheet 名称
|
||||
for i, table in enumerate(tables):
|
||||
table_start_line = table["start_line"] - 1 # 转换为 0-based 索引
|
||||
closest_header_text = None
|
||||
|
||||
# 查找当前表格上方最近的标题
|
||||
candidate_headers = [
|
||||
h for h in all_headers if h["line_num"] < table_start_line
|
||||
]
|
||||
if candidate_headers:
|
||||
# 找到候选标题中行号最大的,即为最接近的
|
||||
closest_header = max(candidate_headers, key=lambda x: x["line_num"])
|
||||
closest_header_text = closest_header["text"]
|
||||
|
||||
if closest_header_text:
|
||||
# 清理并添加找到的标题
|
||||
sheet_names.append(self.clean_sheet_name(closest_header_text))
|
||||
else:
|
||||
# 如果找不到标题,使用默认名称 "表{i+1}"
|
||||
sheet_names.append(f"表{i+1}")
|
||||
|
||||
# 3. 根据表格数量确定工作簿名称
|
||||
if len(tables) == 1:
|
||||
# 单个表格: 使用其工作表名作为工作簿名 (前提是该名称不是默认的 "表1")
|
||||
if sheet_names[0] != "表1":
|
||||
workbook_name = sheet_names[0]
|
||||
elif len(tables) > 1:
|
||||
# 多个表格: 使用文档中的第一个标题作为工作簿名
|
||||
if all_headers:
|
||||
# 找到所有标题中行号最小的,即为第一个标题
|
||||
first_header = min(all_headers, key=lambda x: x["line_num"])
|
||||
workbook_name = first_header["text"]
|
||||
|
||||
# 4. 清理工作簿名称 (如果为空,主流程会使用默认名称)
|
||||
workbook_name = self.clean_filename(workbook_name) if workbook_name else ""
|
||||
|
||||
return workbook_name, sheet_names
|
||||
|
||||
def clean_filename(self, name: str) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()
|
||||
|
||||
def clean_sheet_name(self, name: str) -> str:
|
||||
"""清理sheet名称(限制31字符,去除非法字符)"""
|
||||
name = re.sub(r"[\\/*?[\]:]", "", name).strip()
|
||||
return name[:31] if len(name) > 31 else name
|
||||
|
||||
# ======================== 符合中国规范的格式化功能 ========================
|
||||
|
||||
def calculate_text_width(self, text: str) -> float:
|
||||
"""
|
||||
计算文本显示宽度,考虑中英文字符差异
|
||||
中文字符按2个单位计算,英文字符按1个单位计算
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
width = 0
|
||||
for char in str(text):
|
||||
# 判断是否为中文字符(包括中文标点)
|
||||
if "\u4e00" <= char <= "\u9fff" or "\u3000" <= char <= "\u303f":
|
||||
width += 2 # 中文字符占2个单位宽度
|
||||
else:
|
||||
width += 1 # 英文字符占1个单位宽度
|
||||
|
||||
return width
|
||||
|
||||
def calculate_text_height(self, text: str, max_width: int = 50) -> int:
|
||||
"""
|
||||
计算文本显示所需的行数
|
||||
根据换行符和文本长度计算
|
||||
"""
|
||||
if not text:
|
||||
return 1
|
||||
|
||||
text = str(text)
|
||||
# 计算换行符导致的行数
|
||||
explicit_lines = text.count("\n") + 1
|
||||
|
||||
# 计算因文本长度超出而需要的额外行数
|
||||
text_width = self.calculate_text_width(text.replace("\n", ""))
|
||||
wrapped_lines = max(
|
||||
1, int(text_width / max_width) + (1 if text_width % max_width > 0 else 0)
|
||||
)
|
||||
|
||||
return max(explicit_lines, wrapped_lines)
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def determine_content_type(self, header: str, values: list) -> str:
|
||||
"""
|
||||
根据表头和内容智能判断数据类型,符合中国官方表格规范
|
||||
返回: 'number', 'date', 'sequence', 'text'
|
||||
"""
|
||||
header_lower = str(header).lower().strip()
|
||||
|
||||
# 检查表头关键词
|
||||
number_keywords = [
|
||||
"数量",
|
||||
"金额",
|
||||
"价格",
|
||||
"费用",
|
||||
"成本",
|
||||
"收入",
|
||||
"支出",
|
||||
"总计",
|
||||
"小计",
|
||||
"百分比",
|
||||
"%",
|
||||
"比例",
|
||||
"率",
|
||||
"数值",
|
||||
"分数",
|
||||
"成绩",
|
||||
"得分",
|
||||
]
|
||||
date_keywords = ["日期", "时间", "年份", "月份", "时刻", "date", "time"]
|
||||
sequence_keywords = [
|
||||
"序号",
|
||||
"编号",
|
||||
"号码",
|
||||
"排序",
|
||||
"次序",
|
||||
"顺序",
|
||||
"id",
|
||||
"编码",
|
||||
]
|
||||
|
||||
# 检查表头
|
||||
for keyword in number_keywords:
|
||||
if keyword in header_lower:
|
||||
return "number"
|
||||
|
||||
for keyword in date_keywords:
|
||||
if keyword in header_lower:
|
||||
return "date"
|
||||
|
||||
for keyword in sequence_keywords:
|
||||
if keyword in header_lower:
|
||||
return "sequence"
|
||||
|
||||
# 检查数据内容
|
||||
if not values:
|
||||
return "text"
|
||||
|
||||
sample_values = [
|
||||
str(v).strip() for v in values[:10] if str(v).strip()
|
||||
] # 取前10个非空值作为样本
|
||||
if not sample_values:
|
||||
return "text"
|
||||
|
||||
numeric_count = 0
|
||||
date_count = 0
|
||||
sequence_count = 0
|
||||
|
||||
for value in sample_values:
|
||||
# 检查是否为数字
|
||||
try:
|
||||
float(
|
||||
value.replace(",", "")
|
||||
.replace(",", "")
|
||||
.replace("%", "")
|
||||
.replace("%", "")
|
||||
)
|
||||
numeric_count += 1
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 检查是否为日期格式
|
||||
date_patterns = [
|
||||
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?",
|
||||
r"\d{1,2}[-/]\d{1,2}[-/]\d{4}",
|
||||
r"\d{4}\d{2}\d{2}",
|
||||
]
|
||||
for pattern in date_patterns:
|
||||
if re.match(pattern, value):
|
||||
date_count += 1
|
||||
break
|
||||
|
||||
# 检查是否为序号格式
|
||||
if (
|
||||
re.match(r"^\d+$", value) and len(value) <= 4
|
||||
): # 纯数字且不超过4位,可能是序号
|
||||
sequence_count += 1
|
||||
|
||||
total_count = len(sample_values)
|
||||
|
||||
# 根据比例判断类型
|
||||
if numeric_count / total_count >= 0.7:
|
||||
return "number"
|
||||
elif date_count / total_count >= 0.7:
|
||||
return "date"
|
||||
elif sequence_count / total_count >= 0.8 and sequence_count > 2:
|
||||
return "sequence"
|
||||
else:
|
||||
return "text"
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def save_tables_to_excel_enhanced(
|
||||
self, tables: List[Dict], file_path: str, sheet_names: List[str]
|
||||
):
|
||||
"""
|
||||
符合中国官方表格规范的Excel保存功能
|
||||
"""
|
||||
try:
|
||||
with pd.ExcelWriter(file_path, engine="xlsxwriter") as writer:
|
||||
workbook = writer.book
|
||||
|
||||
# 定义表头样式 - 居中对齐(符合中国规范)
|
||||
header_format = workbook.add_format(
|
||||
{
|
||||
"bold": True,
|
||||
"font_size": 12,
|
||||
"font_color": "white",
|
||||
"bg_color": "#00abbd",
|
||||
"border": 1,
|
||||
"align": "center", # 表头居中
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 文本单元格样式 - 左对齐
|
||||
text_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "left", # 文本左对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 数值单元格样式 - 右对齐
|
||||
number_format = workbook.add_format(
|
||||
{"border": 1, "align": "right", "valign": "vcenter"} # 数值右对齐
|
||||
)
|
||||
|
||||
# 整数格式 - 右对齐
|
||||
integer_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"border": 1,
|
||||
"align": "right", # 整数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 小数格式 - 右对齐
|
||||
decimal_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"border": 1,
|
||||
"align": "right", # 小数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 日期格式 - 居中对齐
|
||||
date_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 日期居中对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 序号格式 - 居中对齐
|
||||
sequence_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 序号居中对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
try:
|
||||
table_data = table["data"]
|
||||
if not table_data or len(table_data) < 1:
|
||||
print(f"Skipping empty table at index {i}")
|
||||
continue
|
||||
|
||||
print(f"Processing table {i+1} with {len(table_data)} rows")
|
||||
|
||||
# 获取sheet名称
|
||||
sheet_name = (
|
||||
sheet_names[i] if i < len(sheet_names) else f"表{i+1}"
|
||||
)
|
||||
|
||||
# 创建DataFrame
|
||||
headers = [
|
||||
str(cell).strip()
|
||||
for cell in table_data[0]
|
||||
if str(cell).strip()
|
||||
]
|
||||
if not headers:
|
||||
print(f"Warning: No valid headers found for table {i+1}")
|
||||
headers = [f"列{j+1}" for j in range(len(table_data[0]))]
|
||||
|
||||
data_rows = []
|
||||
if len(table_data) > 1:
|
||||
max_cols = len(headers)
|
||||
for row in table_data[1:]:
|
||||
processed_row = []
|
||||
for j in range(max_cols):
|
||||
if j < len(row):
|
||||
processed_row.append(str(row[j]))
|
||||
else:
|
||||
processed_row.append("")
|
||||
data_rows.append(processed_row)
|
||||
df = pd.DataFrame(data_rows, columns=headers)
|
||||
else:
|
||||
df = pd.DataFrame(columns=headers)
|
||||
|
||||
print(f"DataFrame created with columns: {list(df.columns)}")
|
||||
|
||||
# 修复pandas FutureWarning - 使用try-except替代errors='ignore'
|
||||
for col in df.columns:
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 先写入数据(不包含表头)
|
||||
df.to_excel(
|
||||
writer,
|
||||
sheet_name=sheet_name,
|
||||
index=False,
|
||||
header=False,
|
||||
startrow=1,
|
||||
)
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# 应用符合中国规范的格式化
|
||||
self.apply_chinese_standard_formatting(
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing table {i+1}: {str(e)}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving Excel file: {str(e)}")
|
||||
raise
|
||||
|
||||
def apply_chinese_standard_formatting(
|
||||
self,
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
):
|
||||
"""
|
||||
应用符合中国官方表格规范的格式化
|
||||
- 表头: 居中对齐
|
||||
- 数值: 右对齐
|
||||
- 文本: 左对齐
|
||||
- 日期: 居中对齐
|
||||
- 序号: 居中对齐
|
||||
"""
|
||||
try:
|
||||
# 1. 写入表头(居中对齐)
|
||||
print(f"Writing headers with Chinese standard alignment: {headers}")
|
||||
for col_idx, header in enumerate(headers):
|
||||
if header and str(header).strip():
|
||||
worksheet.write(0, col_idx, str(header).strip(), header_format)
|
||||
else:
|
||||
default_header = f"列{col_idx+1}"
|
||||
worksheet.write(0, col_idx, default_header, header_format)
|
||||
|
||||
# 2. 分析每列的数据类型并应用相应格式
|
||||
column_types = {}
|
||||
for col_idx, column in enumerate(headers):
|
||||
if col_idx < len(df.columns):
|
||||
column_values = df.iloc[:, col_idx].tolist()
|
||||
column_types[col_idx] = self.determine_content_type(
|
||||
column, column_values
|
||||
)
|
||||
print(
|
||||
f"Column '{column}' determined as type: {column_types[col_idx]}"
|
||||
)
|
||||
else:
|
||||
column_types[col_idx] = "text"
|
||||
|
||||
# 3. 写入并格式化数据(根据类型使用不同对齐方式)
|
||||
for row_idx, row in df.iterrows():
|
||||
for col_idx, value in enumerate(row):
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
|
||||
# 根据内容类型选择格式
|
||||
if content_type == "number":
|
||||
# 数值类型 - 右对齐
|
||||
if pd.api.types.is_numeric_dtype(df.iloc[:, col_idx]):
|
||||
if pd.api.types.is_integer_dtype(df.iloc[:, col_idx]):
|
||||
current_format = integer_format
|
||||
else:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
if numeric_value.is_integer():
|
||||
current_format = integer_format
|
||||
value = int(numeric_value)
|
||||
else:
|
||||
current_format = decimal_format
|
||||
except (ValueError, TypeError):
|
||||
current_format = decimal_format
|
||||
else:
|
||||
current_format = number_format
|
||||
|
||||
elif content_type == "date":
|
||||
# 日期类型 - 居中对齐
|
||||
current_format = date_format
|
||||
|
||||
elif content_type == "sequence":
|
||||
# 序号类型 - 居中对齐
|
||||
current_format = sequence_format
|
||||
|
||||
else:
|
||||
# 文本类型 - 左对齐
|
||||
current_format = text_format
|
||||
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
|
||||
# 4. 自动调整列宽
|
||||
for col_idx, column in enumerate(headers):
|
||||
col_letter = self.get_column_letter(col_idx)
|
||||
|
||||
# 计算表头宽度
|
||||
header_width = self.calculate_text_width(str(column))
|
||||
|
||||
# 计算数据列的最大宽度
|
||||
max_data_width = 0
|
||||
if not df.empty and col_idx < len(df.columns):
|
||||
for value in df.iloc[:, col_idx]:
|
||||
value_width = self.calculate_text_width(str(value))
|
||||
max_data_width = max(max_data_width, value_width)
|
||||
|
||||
# 基础宽度:取表头和数据的最大宽度
|
||||
base_width = max(header_width, max_data_width)
|
||||
|
||||
# 根据内容类型调整宽度
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
if content_type == "sequence":
|
||||
# 序号列通常比较窄
|
||||
optimal_width = max(8, min(15, base_width + 2))
|
||||
elif content_type == "number":
|
||||
# 数值列需要额外空间显示数字
|
||||
optimal_width = max(12, min(25, base_width + 3))
|
||||
elif content_type == "date":
|
||||
# 日期列需要固定宽度
|
||||
optimal_width = max(15, min(20, base_width + 2))
|
||||
else:
|
||||
# 文本列根据内容调整
|
||||
if base_width <= 10:
|
||||
optimal_width = base_width + 3
|
||||
elif base_width <= 20:
|
||||
optimal_width = base_width + 4
|
||||
else:
|
||||
optimal_width = base_width + 5
|
||||
optimal_width = max(10, min(60, optimal_width))
|
||||
|
||||
worksheet.set_column(f"{col_letter}:{col_letter}", optimal_width)
|
||||
|
||||
# 5. 自动调整行高
|
||||
# 设置表头行高为35点
|
||||
worksheet.set_row(0, 35)
|
||||
|
||||
# 设置数据行行高
|
||||
for row_idx, row in df.iterrows():
|
||||
max_row_height = 20 # 中国表格规范建议的最小行高
|
||||
|
||||
for col_idx, value in enumerate(row):
|
||||
if col_idx < len(headers):
|
||||
col_width = min(
|
||||
60,
|
||||
max(
|
||||
10, self.calculate_text_width(str(headers[col_idx])) + 5
|
||||
),
|
||||
)
|
||||
else:
|
||||
col_width = 15
|
||||
|
||||
cell_lines = self.calculate_text_height(str(value), col_width)
|
||||
cell_height = cell_lines * 20 # 每行20点高度,符合中国规范
|
||||
|
||||
max_row_height = max(max_row_height, cell_height)
|
||||
|
||||
final_height = min(120, max_row_height)
|
||||
worksheet.set_row(row_idx + 1, final_height)
|
||||
|
||||
print(f"Successfully applied Chinese standard formatting")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to apply Chinese standard formatting: {str(e)}")
|
||||
# 降级到基础格式化
|
||||
self.apply_basic_formatting_fallback(worksheet, df)
|
||||
|
||||
def apply_basic_formatting_fallback(self, worksheet, df):
|
||||
"""
|
||||
基础格式化降级方案
|
||||
"""
|
||||
try:
|
||||
# 基础列宽调整
|
||||
for i, column in enumerate(df.columns):
|
||||
column_width = (
|
||||
max(
|
||||
len(str(column)),
|
||||
(df[column].astype(str).map(len).max() if not df.empty else 0),
|
||||
)
|
||||
+ 2
|
||||
)
|
||||
|
||||
col_letter = self.get_column_letter(i)
|
||||
worksheet.set_column(
|
||||
f"{col_letter}:{col_letter}", min(60, max(10, column_width))
|
||||
)
|
||||
|
||||
print("Applied basic formatting fallback")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Even basic formatting failed: {str(e)}")
|
||||
15
plugins/actions/knowledge-card/README.md
Normal file
15
plugins/actions/knowledge-card/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Flash Card
|
||||
|
||||
Quickly generates beautiful flashcards from text, extracting key points and categories for efficient learning.
|
||||
|
||||
## Features
|
||||
|
||||
- **Instant Generation**: Turn any text into a structured flashcard.
|
||||
- **Key Point Extraction**: Automatically identifies core concepts.
|
||||
- **Visual Design**: Generates a visually appealing HTML card.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. Send text to the chat.
|
||||
3. The plugin will analyze the text and generate a flashcard.
|
||||
15
plugins/actions/knowledge-card/README_CN.md
Normal file
15
plugins/actions/knowledge-card/README_CN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 闪记卡 (Flash Card)
|
||||
|
||||
快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类,助力高效学习。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **即时生成**:将任何文本转化为结构化的记忆卡片。
|
||||
- **要点提取**:自动识别核心概念。
|
||||
- **视觉设计**:生成视觉精美的 HTML 卡片。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 发送文本到聊天框。
|
||||
3. 插件将分析文本并生成一张闪记卡。
|
||||
554
plugins/actions/knowledge-card/knowledge_card.py
Normal file
554
plugins/actions/knowledge-card/knowledge_card.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
title: 闪记卡 (Flash Card)
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.2.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjRkZENzAwIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRkZBNzAwIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEzIDJMMyA3djEzbDEwIDV2LTZ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTEzIDJ2Nmw4LTN2MTNsLTggM3YtNnoiIGZpbGw9IiM2NjdlZWEiLz48cGF0aCBkPSJNMTMgMnY2bTAgNXYxMCIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLW9wYWNpdHk9IjAuMyIvPjwvc3ZnPg==
|
||||
description: 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
model_id: str = Field(
|
||||
default="",
|
||||
description="用于生成卡片内容的模型 ID。如果为空,则使用当前模型。",
|
||||
)
|
||||
min_text_length: int = Field(
|
||||
default=50, description="生成闪记卡所需的最小文本长度(字符数)。"
|
||||
)
|
||||
max_text_length: int = Field(
|
||||
default=2000,
|
||||
description="建议的最大文本长度。超过此长度建议使用深度分析工具。",
|
||||
)
|
||||
language: str = Field(
|
||||
default="zh", description="卡片内容的目标语言 (例如 'zh', 'en')。"
|
||||
)
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示状态更新。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
) -> Optional[dict]:
|
||||
print(f"action:{__name__} triggered")
|
||||
|
||||
if not __event_emitter__:
|
||||
return body
|
||||
|
||||
# Get the last user message
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
# Usually the action is triggered on the last message
|
||||
target_message = messages[-1]["content"]
|
||||
|
||||
# Check text length
|
||||
text_length = len(target_message)
|
||||
if text_length < self.valves.min_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "warning",
|
||||
"content": f"文本过短({text_length}字符),建议至少{self.valves.min_text_length}字符。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
if text_length > self.valves.max_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本较长({text_length}字符),建议使用'墨海拾贝'进行深度分析。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Notify user that we are generating the card
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "⚡ 正在生成闪记卡...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Extract information using LLM
|
||||
user_id = __user__.get("id") if __user__ else "default"
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
model = self.valves.model_id if self.valves.model_id else body.get("model")
|
||||
|
||||
system_prompt = f"""
|
||||
你是一个闪记卡生成专家,专注于创建适合学习和记忆的知识卡片。你的任务是将文本提炼成简洁、易记的学习卡片。
|
||||
|
||||
请提取以下字段,并以 JSON 格式返回:
|
||||
1. "title": 创建一个简短、精准的标题(6-12 字),突出核心概念
|
||||
2. "summary": 用一句话总结核心要义(20-40 字),要通俗易懂、便于记忆
|
||||
3. "key_points": 列出 3-5 个关键记忆点(每个 10-20 字)
|
||||
- 每个要点应该是独立的知识点
|
||||
- 使用简洁、口语化的表达
|
||||
- 避免冗长的句子
|
||||
4. "tags": 列出 2-4 个分类标签(每个 2-5 字)
|
||||
5. "category": 选择一个主分类(如:概念、技能、事实、方法等)
|
||||
|
||||
目标语言: {self.valves.language}
|
||||
|
||||
重要原则:
|
||||
- **极简主义**: 每个要点都要精炼到极致
|
||||
- **记忆优先**: 内容要便于记忆和回忆
|
||||
- **核心聚焦**: 只提取最核心的知识点
|
||||
- **口语化**: 使用通俗易懂的语言
|
||||
- 只返回 JSON 对象,不要包含 markdown 格式
|
||||
"""
|
||||
|
||||
prompt = f"请将以下文本提炼成一张学习记忆卡片:\n\n{target_message}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
# simple cleanup in case of markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
card_data = json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON: {e}, content: {content}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": "生成卡片数据失败,请重试。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
# 2. Generate HTML
|
||||
html_card = self.generate_html_card(card_data)
|
||||
|
||||
# 3. Append to message
|
||||
# We append it to the user message so it shows up as part of the interaction
|
||||
# Or we can append it to the assistant response if we were a Pipe, but this is an Action.
|
||||
# Actions usually modify the input or trigger a side effect.
|
||||
# To show the card, we can append it to the message content.
|
||||
|
||||
html_embed_tag = f"```html\n{html_card}\n```"
|
||||
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": "⚡ 闪记卡生成成功!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating knowledge card: {e}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"生成知识卡片时出错: {str(e)}",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
def generate_html_card(self, data):
|
||||
# Enhanced CSS with premium styling
|
||||
style = """
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.knowledge-card-container {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 25px 50px -12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.knowledge-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #4facfe);
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: -1;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.knowledge-card:hover::before {
|
||||
opacity: 0.7;
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.75em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 28px;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 1.05em;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
border-left: 5px solid #764ba2;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
font-size: 4em;
|
||||
color: rgba(118, 75, 162, 0.1);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-section-title {
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: #764ba2;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px 12px 44px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
transform: translateX(5px);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.1);
|
||||
}
|
||||
|
||||
.card-points li::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
cursor: default;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-inner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: linear-gradient(to bottom, #1e1e1e 0%, #252525 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
color: rgba(118, 75, 162, 0.2);
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
color: #d1d5db;
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: linear-gradient(to right, #252525 0%, #2d2d2d 100%);
|
||||
border-top-color: #404040;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.knowledge-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
# Enhanced HTML structure
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{style}
|
||||
</head>
|
||||
<body>
|
||||
<div class="knowledge-card-container">
|
||||
<div class="knowledge-card">
|
||||
<div class="card-inner">
|
||||
<div class="card-header">
|
||||
<div class="card-category">{data.get('category', '通用知识')}</div>
|
||||
<h2 class="card-title">{data.get('title', '知识卡片')}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-summary">
|
||||
{data.get('summary', '')}
|
||||
</div>
|
||||
<div class="card-section-title">核心要点</div>
|
||||
<ul class="card-points">
|
||||
{''.join([f'<li>{point}</li>' for point in data.get('key_points', [])])}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-tag-label">标签</span>
|
||||
{''.join([f'<span class="card-tag">#{tag}</span>' for tag in data.get('tags', [])])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return html
|
||||
554
plugins/actions/knowledge-card/knowledge_card_en.py
Normal file
554
plugins/actions/knowledge-card/knowledge_card_en.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
title: Flash Card
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.2.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjRkZENzAwIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRkZBNzAwIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEzIDJMMyA3djEzbDEwIDV2LTZ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTEzIDJ2Nmw4LTN2MTNsLTggM3YtNnoiIGZpbGw9IiM2NjdlZWEiLz48cGF0aCBkPSJNMTMgMnY2bTAgNXYxMCIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLW9wYWNpdHk9IjAuMyIvPjwvc3ZnPg==
|
||||
description: Quickly generates beautiful flashcards from text, extracting key points and categories.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
model_id: str = Field(
|
||||
default="",
|
||||
description="用于生成卡片内容的模型 ID。如果为空,则使用当前模型。",
|
||||
)
|
||||
min_text_length: int = Field(
|
||||
default=50, description="生成闪记卡所需的最小文本长度(字符数)。"
|
||||
)
|
||||
max_text_length: int = Field(
|
||||
default=2000,
|
||||
description="建议的最大文本长度。超过此长度建议使用深度分析工具。",
|
||||
)
|
||||
language: str = Field(
|
||||
default="zh", description="卡片内容的目标语言 (例如 'zh', 'en')。"
|
||||
)
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示状态更新。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
) -> Optional[dict]:
|
||||
print(f"action:{__name__} triggered")
|
||||
|
||||
if not __event_emitter__:
|
||||
return body
|
||||
|
||||
# Get the last user message
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
# Usually the action is triggered on the last message
|
||||
target_message = messages[-1]["content"]
|
||||
|
||||
# Check text length
|
||||
text_length = len(target_message)
|
||||
if text_length < self.valves.min_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "warning",
|
||||
"content": f"文本过短({text_length}字符),建议至少{self.valves.min_text_length}字符。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
if text_length > self.valves.max_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本较长({text_length}字符),建议使用'墨海拾贝'进行深度分析。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Notify user that we are generating the card
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "⚡ 正在生成闪记卡...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Extract information using LLM
|
||||
user_id = __user__.get("id") if __user__ else "default"
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
model = self.valves.model_id if self.valves.model_id else body.get("model")
|
||||
|
||||
system_prompt = f"""
|
||||
你是一个闪记卡生成专家,专注于创建适合学习和记忆的知识卡片。你的任务是将文本提炼成简洁、易记的学习卡片。
|
||||
|
||||
请提取以下字段,并以 JSON 格式返回:
|
||||
1. "title": 创建一个简短、精准的标题(6-12 字),突出核心概念
|
||||
2. "summary": 用一句话总结核心要义(20-40 字),要通俗易懂、便于记忆
|
||||
3. "key_points": 列出 3-5 个关键记忆点(每个 10-20 字)
|
||||
- 每个要点应该是独立的知识点
|
||||
- 使用简洁、口语化的表达
|
||||
- 避免冗长的句子
|
||||
4. "tags": 列出 2-4 个分类标签(每个 2-5 字)
|
||||
5. "category": 选择一个主分类(如:概念、技能、事实、方法等)
|
||||
|
||||
目标语言: {self.valves.language}
|
||||
|
||||
重要原则:
|
||||
- **极简主义**: 每个要点都要精炼到极致
|
||||
- **记忆优先**: 内容要便于记忆和回忆
|
||||
- **核心聚焦**: 只提取最核心的知识点
|
||||
- **口语化**: 使用通俗易懂的语言
|
||||
- 只返回 JSON 对象,不要包含 markdown 格式
|
||||
"""
|
||||
|
||||
prompt = f"请将以下文本提炼成一张学习记忆卡片:\n\n{target_message}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
# simple cleanup in case of markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
card_data = json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON: {e}, content: {content}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": "生成卡片数据失败,请重试。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
# 2. Generate HTML
|
||||
html_card = self.generate_html_card(card_data)
|
||||
|
||||
# 3. Append to message
|
||||
# We append it to the user message so it shows up as part of the interaction
|
||||
# Or we can append it to the assistant response if we were a Pipe, but this is an Action.
|
||||
# Actions usually modify the input or trigger a side effect.
|
||||
# To show the card, we can append it to the message content.
|
||||
|
||||
html_embed_tag = f"```html\n{html_card}\n```"
|
||||
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": "⚡ 闪记卡生成成功!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating knowledge card: {e}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"生成知识卡片时出错: {str(e)}",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
def generate_html_card(self, data):
|
||||
# Enhanced CSS with premium styling
|
||||
style = """
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.knowledge-card-container {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 25px 50px -12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.knowledge-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #4facfe);
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: -1;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.knowledge-card:hover::before {
|
||||
opacity: 0.7;
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.75em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 28px;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 1.05em;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
border-left: 5px solid #764ba2;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
font-size: 4em;
|
||||
color: rgba(118, 75, 162, 0.1);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-section-title {
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: #764ba2;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px 12px 44px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
transform: translateX(5px);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.1);
|
||||
}
|
||||
|
||||
.card-points li::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
cursor: default;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-inner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: linear-gradient(to bottom, #1e1e1e 0%, #252525 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
color: rgba(118, 75, 162, 0.2);
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
color: #d1d5db;
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: linear-gradient(to right, #252525 0%, #2d2d2d 100%);
|
||||
border-top-color: #404040;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.knowledge-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
# Enhanced HTML structure
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{style}
|
||||
</head>
|
||||
<body>
|
||||
<div class="knowledge-card-container">
|
||||
<div class="knowledge-card">
|
||||
<div class="card-inner">
|
||||
<div class="card-header">
|
||||
<div class="card-category">{data.get('category', '通用知识')}</div>
|
||||
<h2 class="card-title">{data.get('title', '知识卡片')}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-summary">
|
||||
{data.get('summary', '')}
|
||||
</div>
|
||||
<div class="card-section-title">核心要点</div>
|
||||
<ul class="card-points">
|
||||
{''.join([f'<li>{point}</li>' for point in data.get('key_points', [])])}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-tag-label">标签</span>
|
||||
{''.join([f'<span class="card-tag">#{tag}</span>' for tag in data.get('tags', [])])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return html
|
||||
210
plugins/actions/smart-mind-map/README.md
Normal file
210
plugins/actions/smart-mind-map/README.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Smart Mind Map - Mind Mapping Generation Plugin
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.7.2 | **License:** MIT
|
||||
|
||||
> **Important**: To ensure the maintainability and usability of all plugins, each plugin should be accompanied by clear and comprehensive documentation to ensure its functionality, configuration, and usage are well explained.
|
||||
|
||||
Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes long-form text content and automatically generates interactive mind maps, helping users structure and visualize knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
- ✅ **Intelligent Text Analysis**: Automatically identifies core themes, key concepts, and hierarchical structures
|
||||
- ✅ **Interactive Visualization**: Generates beautiful interactive mind maps based on Markmap.js
|
||||
- ✅ **Multi-language Support**: Automatically adjusts output based on user language
|
||||
- ✅ **Real-time Rendering**: Renders mind maps directly in the chat interface without navigation
|
||||
- ✅ **Export Capabilities**: Supports copying SVG code and Markdown source
|
||||
- ✅ **Customizable Configuration**: Configurable LLM model, minimum text length, and other parameters
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Text Extraction**: Extracts text content from user messages (automatically filters HTML code blocks)
|
||||
2. **Intelligent Analysis**: Analyzes text structure using the configured LLM model
|
||||
3. **Markdown Generation**: Converts analysis results to Markmap-compatible Markdown format
|
||||
4. **Visual Rendering**: Renders the mind map using Markmap.js in an HTML template
|
||||
5. **Interactive Display**: Presents the mind map to users in an interactive format within the chat interface
|
||||
|
||||
---
|
||||
|
||||
## Installation and Configuration
|
||||
|
||||
### 1. Plugin Installation
|
||||
|
||||
1. Download the `思维导图.py` file to your local computer
|
||||
2. In OpenWebUI Admin Settings, find the "Plugins" section
|
||||
3. Select "Actions" type
|
||||
4. Upload the downloaded file
|
||||
5. Refresh the page, and the plugin will be available
|
||||
|
||||
### 2. Model Configuration
|
||||
|
||||
The plugin requires access to an LLM model for text analysis. Please ensure:
|
||||
|
||||
- Your OpenWebUI instance has at least one available LLM model configured
|
||||
- Recommended to use fast, economical models (e.g., `gemini-2.5-flash`) for the best experience
|
||||
- Configure the `LLM_MODEL_ID` parameter in the plugin settings
|
||||
|
||||
### 3. Plugin Activation
|
||||
|
||||
Select the "Smart Mind Map" action plugin in chat settings to enable it.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
You can adjust the following parameters in the plugin's settings (Valves):
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `show_status` | `true` | Whether to display operation status updates in the chat interface (e.g., "Analyzing..."). |
|
||||
| `LLM_MODEL_ID` | `gemini-2.5-flash` | LLM model ID for text analysis. Recommended to use fast and economical models. |
|
||||
| `MIN_TEXT_LENGTH` | `100` | Minimum text length (in characters) required for mind map analysis. Text that's too short cannot generate valid mind maps. |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
1. Enable the "Smart Mind Map" action in chat settings
|
||||
2. Input or paste long-form text content (at least 100 characters) in the conversation
|
||||
3. After sending the message, the plugin will automatically analyze and generate a mind map
|
||||
4. The mind map will be rendered directly in the chat interface
|
||||
|
||||
### Usage Example
|
||||
|
||||
**Input Text:**
|
||||
```
|
||||
Artificial Intelligence (AI) is a branch of computer science dedicated to creating systems capable of performing tasks that typically require human intelligence.
|
||||
Main application areas include:
|
||||
1. Machine Learning - Enables computers to learn from data
|
||||
2. Natural Language Processing - Understanding and generating human language
|
||||
3. Computer Vision - Recognizing and processing images
|
||||
4. Robotics - Creating intelligent systems that can interact with the physical world
|
||||
```
|
||||
|
||||
**Generated Result:**
|
||||
The plugin will generate an interactive mind map centered on "Artificial Intelligence", including major application areas and their sub-concepts.
|
||||
|
||||
### Export Features
|
||||
|
||||
Generated mind maps support two export methods:
|
||||
|
||||
1. **Copy SVG Code**: Click the "Copy SVG Code" button to copy the mind map in SVG format to the clipboard
|
||||
2. **Copy Markdown**: Click the "Copy Markdown" button to copy the raw Markdown format to the clipboard
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Frontend Rendering
|
||||
|
||||
- **Markmap.js**: Open-source mind mapping rendering engine
|
||||
- **D3.js**: Data visualization foundation library
|
||||
- **Responsive Design**: Adapts to different screen sizes
|
||||
|
||||
### Backend Processing
|
||||
|
||||
- **LLM Integration**: Calls configured models via `generate_chat_completion`
|
||||
- **Text Preprocessing**: Automatically filters HTML code blocks, extracts plain text content
|
||||
- **Format Conversion**: Converts LLM output to Markmap-compatible Markdown format
|
||||
|
||||
### Security
|
||||
|
||||
- **XSS Protection**: Automatically escapes `</script>` tags to prevent script injection
|
||||
- **Input Validation**: Checks text length to avoid invalid requests
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Plugin Won't Start
|
||||
|
||||
**Solution:**
|
||||
- Check OpenWebUI logs for error messages
|
||||
- Confirm the plugin is correctly uploaded and enabled
|
||||
- Verify OpenWebUI version supports action plugins
|
||||
|
||||
### Issue: Text Content Too Short
|
||||
|
||||
**Symptom:** Prompt shows "Text content is too short for effective analysis"
|
||||
|
||||
**Solution:**
|
||||
- Ensure input text contains at least 100 characters (default configuration)
|
||||
- Lower the `MIN_TEXT_LENGTH` parameter value in plugin settings
|
||||
- Provide more detailed, structured text content
|
||||
|
||||
### Issue: Mind Map Not Generated
|
||||
|
||||
**Solution:**
|
||||
- Check if `LLM_MODEL_ID` is configured correctly
|
||||
- Confirm the configured model is available in OpenWebUI
|
||||
- Review backend logs for LLM call failures
|
||||
- Verify user has sufficient permissions to access the configured model
|
||||
|
||||
### Issue: Mind Map Display Error
|
||||
|
||||
**Symptom:** Shows "⚠️ Mind map rendering failed"
|
||||
|
||||
**Solution:**
|
||||
- Check browser console for error messages
|
||||
- Confirm Markmap.js and D3.js libraries are loading correctly
|
||||
- Verify generated Markdown format conforms to Markmap specifications
|
||||
- Try refreshing the page to re-render
|
||||
|
||||
### Issue: Export Function Not Working
|
||||
|
||||
**Solution:**
|
||||
- Confirm browser supports Clipboard API
|
||||
- Check if browser is blocking clipboard access permissions
|
||||
- Use modern browsers (Chrome, Firefox, Edge, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Text Preparation**
|
||||
- Provide text content with clear structure and distinct hierarchies
|
||||
- Use paragraphs, lists, and other formatting to help LLM understand text structure
|
||||
- Avoid excessively lengthy or unstructured text
|
||||
|
||||
2. **Model Selection**
|
||||
- For daily use, recommend fast models like `gemini-2.5-flash`
|
||||
- For complex text analysis, use more powerful models (e.g., GPT-4)
|
||||
- Balance speed and analysis quality based on needs
|
||||
|
||||
3. **Performance Optimization**
|
||||
- Set `MIN_TEXT_LENGTH` appropriately to avoid processing text that's too short
|
||||
- For particularly long texts, consider summarizing before generating mind maps
|
||||
- Disable `show_status` in production environments to reduce interface updates
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.7.2 (Current Version)
|
||||
- Optimized text extraction logic, automatically filters HTML code blocks
|
||||
- Improved error handling and user feedback
|
||||
- Enhanced export functionality compatibility
|
||||
- Optimized UI styling and interactive experience
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This plugin is released under the MIT License.
|
||||
|
||||
## Contributing
|
||||
|
||||
Welcome to submit issue reports and improvement suggestions! Please visit the project repository: [awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
---
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Markmap Official Website](https://markmap.js.org/)
|
||||
- [OpenWebUI Documentation](https://docs.openwebui.com/)
|
||||
- [D3.js Official Website](https://d3js.org/)
|
||||
210
plugins/actions/smart-mind-map/README_CN.md
Normal file
210
plugins/actions/smart-mind-map/README_CN.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 智绘心图 - 思维导图生成插件
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.7.2 | **许可证:** MIT
|
||||
|
||||
> **重要提示**:为了确保所有插件的可维护性和易用性,每个插件都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
|
||||
|
||||
智绘心图是一个强大的 OpenWebUI 动作插件,能够智能分析长篇文本内容,自动生成交互式思维导图,帮助用户结构化和可视化知识。
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
- ✅ **智能文本分析**: 自动识别文本的核心主题、关键概念和层次结构
|
||||
- ✅ **交互式可视化**: 基于 Markmap.js 生成美观的交互式思维导图
|
||||
- ✅ **多语言支持**: 根据用户语言自动调整输出
|
||||
- ✅ **实时渲染**: 在聊天界面中直接渲染思维导图,无需跳转
|
||||
- ✅ **导出功能**: 支持复制 SVG 代码和 Markdown 源码
|
||||
- ✅ **自定义配置**: 可配置 LLM 模型、最小文本长度等参数
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **文本提取**: 从用户消息中提取文本内容(自动过滤 HTML 代码块)
|
||||
2. **智能分析**: 使用配置的 LLM 模型分析文本结构
|
||||
3. **Markdown 生成**: 将分析结果转换为 Markmap 兼容的 Markdown 格式
|
||||
4. **可视化渲染**: 在 HTML 模板中使用 Markmap.js 渲染思维导图
|
||||
5. **交互展示**: 在聊天界面中以可交互的形式展示给用户
|
||||
|
||||
---
|
||||
|
||||
## 安装与配置
|
||||
|
||||
### 1. 插件安装
|
||||
|
||||
1. 下载 `思维导图.py` 文件到本地
|
||||
2. 在 OpenWebUI 管理员设置中找到"插件"(Plugins)部分
|
||||
3. 选择"动作"(Actions)类型
|
||||
4. 上传下载的文件
|
||||
5. 刷新页面,插件即可使用
|
||||
|
||||
### 2. 模型配置
|
||||
|
||||
插件需要访问 LLM 模型来分析文本。请确保:
|
||||
|
||||
- 您的 OpenWebUI 实例中配置了至少一个可用的 LLM 模型
|
||||
- 推荐使用快速、经济的模型(如 `gemini-2.5-flash`)来获得最佳体验
|
||||
- 在插件设置中配置 `LLM_MODEL_ID` 参数
|
||||
|
||||
### 3. 插件启用
|
||||
|
||||
在聊天设置中选择"智绘心图"动作插件即可启用。
|
||||
|
||||
---
|
||||
|
||||
## 配置参数
|
||||
|
||||
您可以在插件的设置(Valves)中调整以下参数:
|
||||
|
||||
| 参数 | 默认值 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| `show_status` | `true` | 是否在聊天界面显示操作状态更新(如"正在分析...")。 |
|
||||
| `LLM_MODEL_ID` | `gemini-2.5-flash` | 用于文本分析的 LLM 模型 ID。推荐使用快速且经济的模型。 |
|
||||
| `MIN_TEXT_LENGTH` | `100` | 进行思维导图分析所需的最小文本长度(字符数)。文本过短将无法生成有效的导图。 |
|
||||
|
||||
---
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本使用
|
||||
|
||||
1. 在聊天设置中启用"智绘心图"动作
|
||||
2. 在对话中输入或粘贴长篇文本内容(至少 100 字符)
|
||||
3. 发送消息后,插件会自动分析并生成思维导图
|
||||
4. 思维导图将在聊天界面中直接渲染显示
|
||||
|
||||
### 使用示例
|
||||
|
||||
**输入文本:**
|
||||
```
|
||||
人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。
|
||||
主要应用领域包括:
|
||||
1. 机器学习 - 使计算机能够从数据中学习
|
||||
2. 自然语言处理 - 理解和生成人类语言
|
||||
3. 计算机视觉 - 识别和处理图像
|
||||
4. 机器人技术 - 创建能够与物理世界交互的智能系统
|
||||
```
|
||||
|
||||
**生成结果:**
|
||||
插件会生成一个以"人工智能"为中心主题的交互式思维导图,包含主要应用领域及其子概念。
|
||||
|
||||
### 导出功能
|
||||
|
||||
生成的思维导图支持两种导出方式:
|
||||
|
||||
1. **复制 SVG 代码**: 点击"复制 SVG 代码"按钮,可将思维导图的 SVG 格式复制到剪贴板
|
||||
2. **复制 Markdown**: 点击"复制 Markdown"按钮,可将原始 Markdown 格式复制到剪贴板
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 前端渲染
|
||||
|
||||
- **Markmap.js**: 开源的思维导图渲染引擎
|
||||
- **D3.js**: 数据可视化基础库
|
||||
- **响应式设计**: 适配不同屏幕尺寸
|
||||
|
||||
### 后端处理
|
||||
|
||||
- **LLM 集成**: 通过 `generate_chat_completion` 调用配置的模型
|
||||
- **文本预处理**: 自动过滤 HTML 代码块,提取纯文本内容
|
||||
- **格式转换**: 将 LLM 输出转换为 Markmap 兼容的 Markdown 格式
|
||||
|
||||
### 安全性
|
||||
|
||||
- **XSS 防护**: 自动转义 `</script>` 标签,防止脚本注入
|
||||
- **输入验证**: 检查文本长度,避免无效请求
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题:插件无法启动
|
||||
|
||||
**解决方案:**
|
||||
- 检查 OpenWebUI 日志,查看是否有错误信息
|
||||
- 确认插件已正确上传并启用
|
||||
- 验证 OpenWebUI 版本是否支持动作插件
|
||||
|
||||
### 问题:文本内容过短
|
||||
|
||||
**现象:** 提示"文本内容过短,无法进行有效分析"
|
||||
|
||||
**解决方案:**
|
||||
- 确保输入的文本至少包含 100 个字符(默认配置)
|
||||
- 可以在插件设置中降低 `MIN_TEXT_LENGTH` 参数值
|
||||
- 提供更详细、结构化的文本内容
|
||||
|
||||
### 问题:思维导图未生成
|
||||
|
||||
**解决方案:**
|
||||
- 检查 `LLM_MODEL_ID` 是否配置正确
|
||||
- 确认配置的模型在 OpenWebUI 中可用
|
||||
- 查看后端日志,检查是否有 LLM 调用失败的错误
|
||||
- 验证用户是否有足够的权限访问配置的模型
|
||||
|
||||
### 问题:思维导图显示错误
|
||||
|
||||
**现象:** 显示"⚠️ 思维导图渲染失败"
|
||||
|
||||
**解决方案:**
|
||||
- 检查浏览器控制台的错误信息
|
||||
- 确认 Markmap.js 和 D3.js 库是否正确加载
|
||||
- 验证生成的 Markdown 格式是否符合 Markmap 规范
|
||||
- 尝试刷新页面重新渲染
|
||||
|
||||
### 问题:导出功能不工作
|
||||
|
||||
**解决方案:**
|
||||
- 确认浏览器支持剪贴板 API
|
||||
- 检查浏览器是否阻止了剪贴板访问权限
|
||||
- 使用现代浏览器(Chrome、Firefox、Edge 等)
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **文本准备**
|
||||
- 提供结构清晰、层次分明的文本内容
|
||||
- 使用段落、列表等格式帮助 LLM 理解文本结构
|
||||
- 避免过于冗长或无结构的文本
|
||||
|
||||
2. **模型选择**
|
||||
- 对于日常使用,推荐 `gemini-2.5-flash` 等快速模型
|
||||
- 对于复杂文本分析,可以使用更强大的模型(如 GPT-4)
|
||||
- 根据需求平衡速度和分析质量
|
||||
|
||||
3. **性能优化**
|
||||
- 合理设置 `MIN_TEXT_LENGTH`,避免处理过短的文本
|
||||
- 对于特别长的文本,考虑先进行摘要再生成思维导图
|
||||
- 在生产环境中关闭 `show_status` 以减少界面更新
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.7.2 (当前版本)
|
||||
- 优化文本提取逻辑,自动过滤 HTML 代码块
|
||||
- 改进错误处理和用户反馈
|
||||
- 增强导出功能的兼容性
|
||||
- 优化 UI 样式和交互体验
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本插件采用 MIT 许可证发布。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交问题报告和改进建议!请访问项目仓库:[awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
---
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Markmap 官方网站](https://markmap.js.org/)
|
||||
- [OpenWebUI 文档](https://docs.openwebui.com/)
|
||||
- [D3.js 官方网站](https://d3js.org/)
|
||||
611
plugins/actions/smart-mind-map/smart_mind_map.py
Normal file
611
plugins/actions/smart-mind-map/smart_mind_map.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
title: Smart Mind Map
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSIxMC41IiB4Mj0iNiIgeTI9IjYiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSIxMC41IiB4Mj0iMTgiIHkyPSI2Ii8+CiAgPGNpcmNsZSBjeD0iMTkiIGN5PSI1IiByPSIxLjUiLz4KICA8bGluZSB4MT0iMTAuNSIgeTE9IjEzLjUiIHgyPSI2IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iMTkiIHI9IjEuNSIvPgogIDxsaW5lIHgxPSIxMy41IiB5MT0iMTMuNSIgeDI9IjE4IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSIxOSIgY3k9IjE5IiByPSIxLjUiLz4KPC9zdmc+
|
||||
version: 0.7.3
|
||||
description: 智能分析长文本并生成交互式思维导图,支持 SVG/Markdown 导出。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT_MINDMAP_ASSISTANT = """
|
||||
You are a professional mind map generation assistant, capable of efficiently analyzing long-form text provided by users and structuring its core themes, key concepts, branches, and sub-branches into standard Markdown list syntax for rendering by Markmap.js.
|
||||
|
||||
Please strictly follow these guidelines:
|
||||
- **Language**: All output must be in the language specified by the user.
|
||||
- **Format**: Your output must strictly be in Markdown list format, wrapped with ```markdown and ```.
|
||||
- Use `#` to define the central theme (root node).
|
||||
- Use `-` with two-space indentation to represent branches and sub-branches.
|
||||
- **Content**:
|
||||
- Identify the central theme of the text as the `#` heading.
|
||||
- Identify main concepts as first-level list items.
|
||||
- Identify supporting details or sub-concepts as nested list items.
|
||||
- Node content should be concise and clear, avoiding verbosity.
|
||||
- **Output Markdown syntax only**: Do not include any additional greetings, explanations, or guiding text.
|
||||
- **If text is too short or cannot generate a valid mind map**: Output a simple Markdown list indicating inability to generate, for example:
|
||||
```markdown
|
||||
# Unable to Generate Mind Map
|
||||
- Reason: Insufficient or unclear text content
|
||||
```
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_MINDMAP = """
|
||||
Please analyze the following long-form text and structure its core themes, key concepts, branches, and sub-branches into standard Markdown list syntax for Markmap.js rendering.
|
||||
|
||||
---
|
||||
**User Context Information:**
|
||||
User Name: {user_name}
|
||||
Current Date & Time: {current_date_time_str}
|
||||
Current Weekday: {current_weekday}
|
||||
Current Timezone: {current_timezone_str}
|
||||
User Language: {user_language}
|
||||
---
|
||||
|
||||
**Long-form Text Content:**
|
||||
{long_text_content}
|
||||
"""
|
||||
|
||||
HTML_TEMPLATE_MINDMAP = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Smart Mind Map: Mind Map Visualization</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-lib@0.17"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.17"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1e88e5;
|
||||
--secondary-color: #43a047;
|
||||
--background-color: #f4f6f8;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #263238;
|
||||
--muted-text-color: #546e7a;
|
||||
--border-color: #e0e0e0;
|
||||
--header-gradient: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||||
--shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--border-radius: 12px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #eceff1;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.user-context span { margin: 4px 10px; }
|
||||
.content-area {
|
||||
padding: 30px 40px;
|
||||
}
|
||||
.markmap-container {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
background-image: radial-gradient(var(--border-color) 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
min-height: 700px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.download-area {
|
||||
text-align: center;
|
||||
padding-top: 30px;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.download-btn {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.download-btn.secondary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.download-btn.copied {
|
||||
background-color: #2e7d32; /* A darker green for success */
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #90a4ae;
|
||||
background-color: #eceff1;
|
||||
}
|
||||
.footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error-message {
|
||||
color: #c62828;
|
||||
background-color: #ffcdd2;
|
||||
border: 1px solid #ef9a9a;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧠 Smart Mind Map</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>User:</strong> {user_name}</span>
|
||||
<span><strong>Analysis Time:</strong> {current_date_time_str}</span>
|
||||
<span><strong>Weekday:</strong> {current_weekday_zh}</span>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
||||
<div class="download-area">
|
||||
<button id="download-svg-btn-{unique_id}" class="download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span class="btn-text">Copy SVG Code</span>
|
||||
</button>
|
||||
<button id="download-md-btn-{unique_id}" class="download-btn secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span class="btn-text">Copy Markdown</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {current_year} Smart Mind Map • Rendering engine powered by <a href="https://markmap.js.org/" target="_blank">Markmap</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const renderMindmap = () => {
|
||||
const uniqueId = "{unique_id}";
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
if (!containerEl || containerEl.dataset.markmapRendered) return;
|
||||
|
||||
const sourceEl = document.getElementById('markdown-source-' + uniqueId);
|
||||
if (!sourceEl) return;
|
||||
|
||||
const markdownContent = sourceEl.textContent.trim();
|
||||
if (!markdownContent) {
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ Unable to load mind map: Missing valid content.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svgEl.style.width = '100%';
|
||||
svgEl.style.height = '700px';
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.appendChild(svgEl);
|
||||
|
||||
const { Transformer, Markmap } = window.markmap;
|
||||
const transformer = new Transformer();
|
||||
const { root } = transformer.transform(markdownContent);
|
||||
|
||||
const style = (id) => `${id} text { font-size: 16px !important; }`;
|
||||
|
||||
const options = {
|
||||
autoFit: true,
|
||||
style: style
|
||||
};
|
||||
Markmap.create(svgEl, options, root);
|
||||
|
||||
containerEl.dataset.markmapRendered = 'true';
|
||||
|
||||
attachDownloadHandlers(uniqueId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Markmap rendering error:', error);
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ Mind map rendering failed!<br>Reason: ' + error.message + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
const attachDownloadHandlers = (uniqueId) => {
|
||||
const downloadSvgBtn = document.getElementById('download-svg-btn-' + uniqueId);
|
||||
const downloadMdBtn = document.getElementById('download-md-btn-' + uniqueId);
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
|
||||
const showFeedback = (button, isSuccess) => {
|
||||
const buttonText = button.querySelector('.btn-text');
|
||||
const originalText = buttonText.textContent;
|
||||
|
||||
button.disabled = true;
|
||||
if (isSuccess) {
|
||||
buttonText.textContent = '✅ Copied!';
|
||||
button.classList.add('copied');
|
||||
} else {
|
||||
buttonText.textContent = '❌ Copy Failed';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
buttonText.textContent = originalText;
|
||||
button.disabled = false;
|
||||
button.classList.remove('copied');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const copyToClipboard = (content, button) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
showFeedback(button, true);
|
||||
}, () => {
|
||||
showFeedback(button, false);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older/insecure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = content;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showFeedback(button, true);
|
||||
} catch (err) {
|
||||
showFeedback(button, false);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
if (downloadSvgBtn) {
|
||||
downloadSvgBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const svgEl = containerEl.querySelector('svg');
|
||||
if (svgEl) {
|
||||
const svgData = new XMLSerializer().serializeToString(svgEl);
|
||||
copyToClipboard(svgData, downloadSvgBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadMdBtn) {
|
||||
downloadMdBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const markdownContent = document.getElementById('markdown-source-' + uniqueId).textContent;
|
||||
copyToClipboard(markdownContent, downloadMdBtn);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderMindmap);
|
||||
} else {
|
||||
renderMindmap();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show action status updates in the chat interface.",
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="Built-in LLM model ID for text analysis.",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=100,
|
||||
description="Minimum text length (character count) required for mind map analysis.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.weekday_map = {
|
||||
"Monday": "Monday",
|
||||
"Tuesday": "Tuesday",
|
||||
"Wednesday": "Wednesday",
|
||||
"Thursday": "Thursday",
|
||||
"Friday": "Friday",
|
||||
"Saturday": "Saturday",
|
||||
"Sunday": "Sunday",
|
||||
}
|
||||
|
||||
def _extract_markdown_syntax(self, llm_output: str) -> str:
|
||||
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
extracted_content = match.group(1).strip()
|
||||
else:
|
||||
logger.warning(
|
||||
"LLM output did not strictly follow the expected Markdown format, treating the entire output as summary."
|
||||
)
|
||||
extracted_content = llm_output.strip()
|
||||
return extracted_content.replace("</script>", "<\\/script>")
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: Smart Mind Map (v0.7.2) started")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "en-US") if __user__ else "en-US"
|
||||
)
|
||||
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
try:
|
||||
shanghai_tz = pytz.timezone("Asia/Shanghai")
|
||||
current_datetime_shanghai = datetime.now(shanghai_tz)
|
||||
current_date_time_str = current_datetime_shanghai.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
current_weekday_en = current_datetime_shanghai.strftime("%A")
|
||||
current_weekday_zh = self.weekday_map.get(current_weekday_en, "Unknown")
|
||||
current_year = current_datetime_shanghai.strftime("%Y")
|
||||
current_timezone_str = "Asia/Shanghai"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get timezone info: {e}, using default values.")
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday_zh = "Unknown"
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "Unknown"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "Smart Mind Map is starting, generating mind map for you...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
messages = body.get("messages")
|
||||
if (
|
||||
not messages
|
||||
or not isinstance(messages, list)
|
||||
or not messages[-1].get("content")
|
||||
):
|
||||
error_message = "Unable to retrieve valid user message content."
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": error_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
|
||||
}
|
||||
|
||||
parts = re.split(r"```html.*?```", messages[-1]["content"], flags=re.DOTALL)
|
||||
long_text_content = ""
|
||||
if parts:
|
||||
for part in reversed(parts):
|
||||
if part.strip():
|
||||
long_text_content = part.strip()
|
||||
break
|
||||
|
||||
if not long_text_content:
|
||||
long_text_content = messages[-1]["content"].strip()
|
||||
|
||||
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"Text content is too short ({len(long_text_content)} characters), unable to perform effective analysis. Please provide at least {self.valves.MIN_TEXT_LENGTH} characters of text."
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Smart Mind Map: Analyzing text structure in depth...",
|
||||
"done": False,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
unique_id = f"id_{int(time.time() * 1000)}"
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_MINDMAP.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday_zh,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=long_text_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_MINDMAP_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
}
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"Unable to get user object, user ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
|
||||
if (
|
||||
not llm_response
|
||||
or "choices" not in llm_response
|
||||
or not llm_response["choices"]
|
||||
):
|
||||
raise ValueError("LLM response format is incorrect or empty.")
|
||||
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
||||
|
||||
final_html_content = (
|
||||
HTML_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
||||
.replace("{user_language}", user_language)
|
||||
.replace("{user_name}", user_name)
|
||||
.replace("{current_date_time_str}", current_date_time_str)
|
||||
.replace("{current_weekday_zh}", current_weekday_zh)
|
||||
.replace("{current_year}", current_year)
|
||||
.replace("{markdown_syntax}", markdown_syntax)
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Smart Mind Map: Drawing completed!",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"Mind map has been generated, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info("Action: Smart Mind Map (v0.7.2) completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Smart Mind Map processing failed: {str(e)}"
|
||||
logger.error(f"Smart Mind Map error: {error_message}", exc_info=True)
|
||||
user_facing_error = f"Sorry, Smart Mind Map encountered an error during processing: {str(e)}.\nPlease check the Open WebUI backend logs for more details."
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{long_text_content}\n\n❌ **Error:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Smart Mind Map: Processing failed.",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"Smart Mind Map generation failed, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
611
plugins/actions/smart-mind-map/思维导图.py
Normal file
611
plugins/actions/smart-mind-map/思维导图.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
title: 智绘心图
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSIxMC41IiB4Mj0iNiIgeTI9IjYiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSIxMC41IiB4Mj0iMTgiIHkyPSI2Ii8+CiAgPGNpcmNsZSBjeD0iMTkiIGN5PSI1IiByPSIxLjUiLz4KICA8bGluZSB4MT0iMTAuNSIgeTE9IjEzLjUiIHgyPSI2IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iMTkiIHI9IjEuNSIvPgogIDxsaW5lIHgxPSIxMy41IiB5MT0iMTMuNSIgeDI9IjE4IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSIxOSIgY3k9IjE5IiByPSIxLjUiLz4KPC9zdmc+
|
||||
version: 0.7.2
|
||||
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT_MINDMAP_ASSISTANT = """
|
||||
你是一个专业的思维导图生成助手,能够高效地分析用户提供的长篇文本,并将其核心主题、关键概念、分支和子分支结构化为标准的Markdown列表语法,以便Markmap.js进行渲染。
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**: 所有输出必须使用用户指定的语言。
|
||||
- **格式**: 你的输出必须严格为Markdown列表格式,并用```markdown 和 ``` 包裹。
|
||||
- 使用 `#` 定义中心主题(根节点)。
|
||||
- 使用 `-` 和两个空格的缩进表示分支和子分支。
|
||||
- **内容**:
|
||||
- 识别文本的中心主题作为 `#` 标题。
|
||||
- 识别主要概念作为一级列表项。
|
||||
- 识别支持性细节或子概念作为嵌套的列表项。
|
||||
- 节点内容应简洁明了,避免冗长。
|
||||
- **只输出Markdown语法**: 不要包含任何额外的寒暄、解释或引导性文字。
|
||||
- **如果文本过短或无法生成有效导图**: 请输出一个简单的Markdown列表,表示无法生成,例如:
|
||||
```markdown
|
||||
# 无法生成思维导图
|
||||
- 原因: 文本内容不足或不明确
|
||||
```
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_MINDMAP = """
|
||||
请分析以下长篇文本,并将其核心主题、关键概念、分支和子分支结构化为标准的Markdown列表语法,以供Markmap.js渲染。
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
Use code with caution.
|
||||
Python
|
||||
{long_text_content}
|
||||
"""
|
||||
|
||||
HTML_TEMPLATE_MINDMAP = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>智绘心图: 思维导图</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-lib@0.17"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.17"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1e88e5;
|
||||
--secondary-color: #43a047;
|
||||
--background-color: #f4f6f8;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #263238;
|
||||
--muted-text-color: #546e7a;
|
||||
--border-color: #e0e0e0;
|
||||
--header-gradient: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||||
--shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--border-radius: 12px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #eceff1;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.user-context span { margin: 4px 10px; }
|
||||
.content-area {
|
||||
padding: 30px 40px;
|
||||
}
|
||||
.markmap-container {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
background-image: radial-gradient(var(--border-color) 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
min-height: 700px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.download-area {
|
||||
text-align: center;
|
||||
padding-top: 30px;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.download-btn {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.download-btn.secondary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.download-btn.copied {
|
||||
background-color: #2e7d32; /* A darker green for success */
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #90a4ae;
|
||||
background-color: #eceff1;
|
||||
}
|
||||
.footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error-message {
|
||||
color: #c62828;
|
||||
background-color: #ffcdd2;
|
||||
border: 1px solid #ef9a9a;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧠 智绘心图</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {user_name}</span>
|
||||
<span><strong>分析时间:</strong> {current_date_time_str}</span>
|
||||
<span><strong>星期:</strong> {current_weekday_zh}</span>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
||||
<div class="download-area">
|
||||
<button id="download-svg-btn-{unique_id}" class="download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span class="btn-text">复制 SVG 代码</span>
|
||||
</button>
|
||||
<button id="download-md-btn-{unique_id}" class="download-btn secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span class="btn-text">复制 Markdown</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {current_year} 智绘心图 • 渲染引擎由 <a href="https://markmap.js.org/" target="_blank">Markmap</a> 提供</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const renderMindmap = () => {
|
||||
const uniqueId = "{unique_id}";
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
if (!containerEl || containerEl.dataset.markmapRendered) return;
|
||||
|
||||
const sourceEl = document.getElementById('markdown-source-' + uniqueId);
|
||||
if (!sourceEl) return;
|
||||
|
||||
const markdownContent = sourceEl.textContent.trim();
|
||||
if (!markdownContent) {
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图: 缺少有效内容。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svgEl.style.width = '100%';
|
||||
svgEl.style.height = '700px';
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.appendChild(svgEl);
|
||||
|
||||
const { Transformer, Markmap } = window.markmap;
|
||||
const transformer = new Transformer();
|
||||
const { root } = transformer.transform(markdownContent);
|
||||
|
||||
const style = (id) => `${id} text { font-size: 16px !important; }`;
|
||||
|
||||
const options = {
|
||||
autoFit: true,
|
||||
style: style
|
||||
};
|
||||
Markmap.create(svgEl, options, root);
|
||||
|
||||
containerEl.dataset.markmapRendered = 'true';
|
||||
|
||||
attachDownloadHandlers(uniqueId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Markmap rendering error:', error);
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 思维导图渲染失败!<br>原因: ' + error.message + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
const attachDownloadHandlers = (uniqueId) => {
|
||||
const downloadSvgBtn = document.getElementById('download-svg-btn-' + uniqueId);
|
||||
const downloadMdBtn = document.getElementById('download-md-btn-' + uniqueId);
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
|
||||
const showFeedback = (button, isSuccess) => {
|
||||
const buttonText = button.querySelector('.btn-text');
|
||||
const originalText = buttonText.textContent;
|
||||
|
||||
button.disabled = true;
|
||||
if (isSuccess) {
|
||||
buttonText.textContent = '✅ 已复制!';
|
||||
button.classList.add('copied');
|
||||
} else {
|
||||
buttonText.textContent = '❌ 复制失败';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
buttonText.textContent = originalText;
|
||||
button.disabled = false;
|
||||
button.classList.remove('copied');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const copyToClipboard = (content, button) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
showFeedback(button, true);
|
||||
}, () => {
|
||||
showFeedback(button, false);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older/insecure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = content;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showFeedback(button, true);
|
||||
} catch (err) {
|
||||
showFeedback(button, false);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
if (downloadSvgBtn) {
|
||||
downloadSvgBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const svgEl = containerEl.querySelector('svg');
|
||||
if (svgEl) {
|
||||
const svgData = new XMLSerializer().serializeToString(svgEl);
|
||||
copyToClipboard(svgData, downloadSvgBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadMdBtn) {
|
||||
downloadMdBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const markdownContent = document.getElementById('markdown-source-' + uniqueId).textContent;
|
||||
copyToClipboard(markdownContent, downloadMdBtn);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderMindmap);
|
||||
} else {
|
||||
renderMindmap();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=100, description="进行思维导图分析所需的最小文本长度(字符数)。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.weekday_map = {
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
}
|
||||
|
||||
def _extract_markdown_syntax(self, llm_output: str) -> str:
|
||||
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
extracted_content = match.group(1).strip()
|
||||
else:
|
||||
logger.warning(
|
||||
"LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。"
|
||||
)
|
||||
extracted_content = llm_output.strip()
|
||||
return extracted_content.replace("</script>", "<\\/script>")
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 智绘心图 (v12 - Final Feedback Fix) started")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
try:
|
||||
shanghai_tz = pytz.timezone("Asia/Shanghai")
|
||||
current_datetime_shanghai = datetime.now(shanghai_tz)
|
||||
current_date_time_str = current_datetime_shanghai.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
current_weekday_en = current_datetime_shanghai.strftime("%A")
|
||||
current_weekday_zh = self.weekday_map.get(current_weekday_en, "未知星期")
|
||||
current_year = current_datetime_shanghai.strftime("%Y")
|
||||
current_timezone_str = "Asia/Shanghai"
|
||||
except Exception as e:
|
||||
logger.warning(f"获取时区信息失败: {e},使用默认值。")
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday_zh = "未知星期"
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "智绘心图已启动,正在为您生成思维导图...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
messages = body.get("messages")
|
||||
if (
|
||||
not messages
|
||||
or not isinstance(messages, list)
|
||||
or not messages[-1].get("content")
|
||||
):
|
||||
error_message = "无法获取有效的用户消息内容。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": error_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
|
||||
}
|
||||
|
||||
parts = re.split(r"```html.*?```", messages[-1]["content"], flags=re.DOTALL)
|
||||
long_text_content = ""
|
||||
if parts:
|
||||
for part in reversed(parts):
|
||||
if part.strip():
|
||||
long_text_content = part.strip()
|
||||
break
|
||||
|
||||
if not long_text_content:
|
||||
long_text_content = messages[-1]["content"].strip()
|
||||
|
||||
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(long_text_content)}字符),无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 深入分析文本结构...",
|
||||
"done": False,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
unique_id = f"id_{int(time.time() * 1000)}"
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_MINDMAP.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday_zh,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=long_text_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_MINDMAP_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
}
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象,用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
|
||||
if (
|
||||
not llm_response
|
||||
or "choices" not in llm_response
|
||||
or not llm_response["choices"]
|
||||
):
|
||||
raise ValueError("LLM响应格式不正确或为空。")
|
||||
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
||||
|
||||
final_html_content = (
|
||||
HTML_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
||||
.replace("{user_language}", user_language)
|
||||
.replace("{user_name}", user_name)
|
||||
.replace("{current_date_time_str}", current_date_time_str)
|
||||
.replace("{current_weekday_zh}", current_weekday_zh)
|
||||
.replace("{current_year}", current_year)
|
||||
.replace("{markdown_syntax}", markdown_syntax)
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 绘制完成!",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"思维导图已生成,{user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info("Action: 智绘心图 (v12) completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"智绘心图处理失败: {str(e)}"
|
||||
logger.error(f"智绘心图错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉,智绘心图在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{long_text_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 处理失败。",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"智绘心图生成失败, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
15
plugins/actions/summary/README.md
Normal file
15
plugins/actions/summary/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Deep Reading & Summary
|
||||
|
||||
A powerful tool for analyzing long texts, generating detailed summaries, key points, and actionable insights.
|
||||
|
||||
## Features
|
||||
|
||||
- **Deep Analysis**: Goes beyond simple summarization to understand the core message.
|
||||
- **Key Point Extraction**: Identifies and lists the most important information.
|
||||
- **Actionable Advice**: Provides practical suggestions based on the text content.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. Send a long text or article to the chat.
|
||||
3. Click the "Deep Reading" button (or trigger via command).
|
||||
15
plugins/actions/summary/README_CN.md
Normal file
15
plugins/actions/summary/README_CN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 深度阅读与摘要 (Deep Reading & Summary)
|
||||
|
||||
一个强大的长文本分析工具,用于生成详细摘要、关键信息点和可执行的行动建议。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **深度分析**:超越简单的总结,深入理解核心信息。
|
||||
- **关键点提取**:识别并列出最重要的信息点。
|
||||
- **行动建议**:基于文本内容提供切实可行的建议。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 发送长文本或文章到聊天框。
|
||||
3. 点击“精读”按钮(或通过命令触发)。
|
||||
527
plugins/actions/summary/summary.py
Normal file
527
plugins/actions/summary/summary.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
title: Deep Reading & Summary
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.1.0
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yIDNIMGEyIDIgMCAwIDAgMiAyIi8+PHBhdGggZD0iTTIyIDNIMjBhMiAyIDAgMCAwLTIgMiIvPjxwYXRoIGQ9Ik0yIDdoMjB2MTRhMiAyIDAgMCAxLTIgMmgtMTZhMiAyIDAgMCAxLTItMnYtMTQiLz48cGF0aCBkPSJNMTEgMTJ2NiIvPjxwYXRoIGQ9Ik0xNiAxMnY2Ii8+PHBhdGggZD0iTTYgMTJ2NiIvPjwvc3ZnPg==
|
||||
description: Provides deep reading analysis and summarization for long texts.
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# 内部 LLM 提示词设计
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_READING_ASSISTANT = """
|
||||
你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。
|
||||
|
||||
请提供以下内容:
|
||||
1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。
|
||||
2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该:
|
||||
- 具体且有深度
|
||||
- 包含必要的细节和背景
|
||||
- 使用 Markdown 列表格式
|
||||
3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该:
|
||||
- 明确且可操作
|
||||
- 包含执行的优先级或时间建议
|
||||
- 如果没有明确的行动项,可以提供学习建议或思考方向
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**:所有输出必须使用用户指定的语言。
|
||||
- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题:
|
||||
## 摘要
|
||||
[这里是详细的摘要内容,2-3段话,可以使用 Markdown 进行**加粗**或*斜体*强调重点]
|
||||
|
||||
## 关键信息点
|
||||
- [关键点1:包含具体细节和背景]
|
||||
- [关键点2:包含具体细节和背景]
|
||||
- [关键点3:包含具体细节和背景]
|
||||
- [至少5个,最多8个关键点]
|
||||
|
||||
## 行动建议
|
||||
- [行动项1:具体、可执行,包含优先级]
|
||||
- [行动项2:具体、可执行,包含优先级]
|
||||
- [如果没有明确行动项,提供学习建议或思考方向]
|
||||
- **深度优先**:分析要深入、全面,不要浮于表面。
|
||||
- **行动导向**:重点关注可执行的建议和下一步行动。
|
||||
- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_SUMMARY = """
|
||||
请对以下长篇文本进行深度分析,提供:
|
||||
1. 详细的摘要(2-3段话,全面概括文本内容)
|
||||
2. 关键信息点列表(5-8个,包含具体细节)
|
||||
3. 可执行的行动建议(具体、明确,包含优先级)
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
请进行深入、全面的分析,重点关注可执行的行动建议。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 前端 HTML 模板 (Jinja2 语法)
|
||||
# =================================================================
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ user_language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>精读:深度分析报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #1e88e5;
|
||||
--action-color: #34a853;
|
||||
--background-color: #f8f9fa;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #202124;
|
||||
--muted-text-color: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
|
||||
--shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
--border-radius: 8px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2.2em;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #f1f3f4;
|
||||
padding: 16px 40px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.user-context span { margin: 4px 12px; }
|
||||
.content { padding: 40px; }
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
}
|
||||
.section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
.section h2 .icon {
|
||||
margin-right: 12px;
|
||||
font-size: 1.3em;
|
||||
line-height: 1;
|
||||
}
|
||||
.summary-section h2 { border-bottom-color: var(--primary-color); }
|
||||
.keypoints-section h2 { border-bottom-color: var(--secondary-color); }
|
||||
.actions-section h2 { border-bottom-color: var(--action-color); }
|
||||
|
||||
.html-content {
|
||||
font-size: 1.05em;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.html-content p:first-child { margin-top: 0; }
|
||||
.html-content p:last-child { margin-bottom: 0; }
|
||||
.html-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.html-content li {
|
||||
padding: 12px 0 12px 32px;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.html-content li::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12px;
|
||||
font-family: 'Arial';
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.keypoints-section .html-content li::before {
|
||||
content: '•';
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.5em;
|
||||
top: 8px;
|
||||
}
|
||||
.actions-section .html-content li::before {
|
||||
content: '▸';
|
||||
color: var(--action-color);
|
||||
}
|
||||
|
||||
.no-content {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #5f6368;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📖 精读:深度分析报告</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {{ user_name }}</span>
|
||||
<span><strong>分析时间:</strong> {{ current_date_time_str }}</span>
|
||||
<span><strong>星期:</strong> {{ current_weekday }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section summary-section">
|
||||
<h2><span class="icon">📝</span>详细摘要</h2>
|
||||
<div class="html-content">{{ summary_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section keypoints-section">
|
||||
<h2><span class="icon">💡</span>关键信息点</h2>
|
||||
<div class="html-content">{{ keypoints_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section actions-section">
|
||||
<h2><span class="icon">🎯</span>行动建议</h2>
|
||||
<div class="html-content">{{ actions_html | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{ current_year }} 精读 - 深度文本分析服务</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200,
|
||||
description="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。",
|
||||
)
|
||||
RECOMMENDED_MIN_LENGTH: int = Field(
|
||||
default=500, description="建议的最小文本长度,以获得最佳分析效果。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""
|
||||
解析LLM的Markdown输出,将其转换为HTML片段。
|
||||
"""
|
||||
summary_match = re.search(
|
||||
r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
keypoints_match = re.search(
|
||||
r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
actions_match = re.search(
|
||||
r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
|
||||
summary_md = summary_match.group(1).strip() if summary_match else ""
|
||||
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
|
||||
actions_md = actions_match.group(1).strip() if actions_match else ""
|
||||
|
||||
if not any([summary_md, keypoints_md, actions_md]):
|
||||
summary_md = llm_output.strip()
|
||||
logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。")
|
||||
|
||||
# 使用 'nl2br' 扩展将换行符 \n 转换为 <br>
|
||||
md_extensions = ["nl2br"]
|
||||
summary_html = (
|
||||
markdown.markdown(summary_md, extensions=md_extensions)
|
||||
if summary_md
|
||||
else '<p class="no-content">未能提取摘要信息。</p>'
|
||||
)
|
||||
keypoints_html = (
|
||||
markdown.markdown(keypoints_md, extensions=md_extensions)
|
||||
if keypoints_md
|
||||
else '<p class="no-content">未能提取关键信息点。</p>'
|
||||
)
|
||||
actions_html = (
|
||||
markdown.markdown(actions_md, extensions=md_extensions)
|
||||
if actions_md
|
||||
else '<p class="no-content">暂无明确的行动建议。</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"summary_html": summary_html,
|
||||
"keypoints_html": keypoints_html,
|
||||
"actions_html": actions_html,
|
||||
}
|
||||
|
||||
def _build_html(self, context: dict) -> str:
|
||||
"""
|
||||
使用 Jinja2 模板和上下文数据构建最终的HTML内容。
|
||||
"""
|
||||
template = Template(HTML_TEMPLATE)
|
||||
return template.render(context)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday = now.strftime("%A")
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages or not messages[-1].get("content"):
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
|
||||
original_content = messages[-1]["content"]
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
# Recommend for longer texts
|
||||
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "📖 精读已启动,正在进行深度分析...",
|
||||
},
|
||||
}
|
||||
)
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "📖 精读: 深入分析文本,提炼精华...",
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象, 用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
|
||||
processed_content = self._process_llm_output(assistant_response_content)
|
||||
|
||||
context = {
|
||||
"user_language": user_language,
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"current_weekday": current_weekday,
|
||||
"current_year": current_year,
|
||||
**processed_content,
|
||||
}
|
||||
|
||||
final_html_content = self._build_html(context)
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "📖 精读: 分析完成!", "done": True},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"📖 精读完成,{user_name}!深度分析报告已生成。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"精读处理失败: {str(e)}"
|
||||
logger.error(f"精读错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "精读: 处理失败。",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"精读处理失败, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
521
plugins/actions/summary/精读.py
Normal file
521
plugins/actions/summary/精读.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
title: 精读 (Deep Reading)
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNDI4NWY0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWU4OGU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTE0IDJsNiA2aC02eiIgZmlsbD0iIzFlODhlNSIgb3BhY2l0eT0iMC42Ii8+PGxpbmUgeDE9IjgiIHkxPSIxMyIgeDI9IjE2IiB5Mj0iMTMiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48bGluZSB4MT0iOCIgeTE9IjE3IiB4Mj0iMTQiIHkyPSIxNyIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiNmZmQ3MDAiLz48cGF0aCBkPSJNMTYgMTZsMS41IDEuNSIgc3Ryb2tlPSIjNDI4NWY0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==
|
||||
version: 2.0.0
|
||||
description: 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议,适合工作和学习场景。
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# 内部 LLM 提示词设计
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_READING_ASSISTANT = """
|
||||
你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。
|
||||
|
||||
请提供以下内容:
|
||||
1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。
|
||||
2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该:
|
||||
- 具体且有深度
|
||||
- 包含必要的细节和背景
|
||||
- 使用 Markdown 列表格式
|
||||
3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该:
|
||||
- 明确且可操作
|
||||
- 包含执行的优先级或时间建议
|
||||
- 如果没有明确的行动项,可以提供学习建议或思考方向
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**:所有输出必须使用用户指定的语言。
|
||||
- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题:
|
||||
## 摘要
|
||||
[这里是详细的摘要内容,2-3段话,可以使用 Markdown 进行**加粗**或*斜体*强调重点]
|
||||
|
||||
## 关键信息点
|
||||
- [关键点1:包含具体细节和背景]
|
||||
- [关键点2:包含具体细节和背景]
|
||||
- [关键点3:包含具体细节和背景]
|
||||
- [至少5个,最多8个关键点]
|
||||
|
||||
## 行动建议
|
||||
- [行动项1:具体、可执行,包含优先级]
|
||||
- [行动项2:具体、可执行,包含优先级]
|
||||
- [如果没有明确行动项,提供学习建议或思考方向]
|
||||
- **深度优先**:分析要深入、全面,不要浮于表面。
|
||||
- **行动导向**:重点关注可执行的建议和下一步行动。
|
||||
- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_SUMMARY = """
|
||||
请对以下长篇文本进行深度分析,提供:
|
||||
1. 详细的摘要(2-3段话,全面概括文本内容)
|
||||
2. 关键信息点列表(5-8个,包含具体细节)
|
||||
3. 可执行的行动建议(具体、明确,包含优先级)
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
请进行深入、全面的分析,重点关注可执行的行动建议。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 前端 HTML 模板 (Jinja2 语法)
|
||||
# =================================================================
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ user_language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>精读:深度分析报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #1e88e5;
|
||||
--action-color: #34a853;
|
||||
--background-color: #f8f9fa;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #202124;
|
||||
--muted-text-color: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
|
||||
--shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
--border-radius: 8px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2.2em;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #f1f3f4;
|
||||
padding: 16px 40px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.user-context span { margin: 4px 12px; }
|
||||
.content { padding: 40px; }
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
}
|
||||
.section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
.section h2 .icon {
|
||||
margin-right: 12px;
|
||||
font-size: 1.3em;
|
||||
line-height: 1;
|
||||
}
|
||||
.summary-section h2 { border-bottom-color: var(--primary-color); }
|
||||
.keypoints-section h2 { border-bottom-color: var(--secondary-color); }
|
||||
.actions-section h2 { border-bottom-color: var(--action-color); }
|
||||
|
||||
.html-content {
|
||||
font-size: 1.05em;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.html-content p:first-child { margin-top: 0; }
|
||||
.html-content p:last-child { margin-bottom: 0; }
|
||||
.html-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.html-content li {
|
||||
padding: 12px 0 12px 32px;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.html-content li::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12px;
|
||||
font-family: 'Arial';
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.keypoints-section .html-content li::before {
|
||||
content: '•';
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.5em;
|
||||
top: 8px;
|
||||
}
|
||||
.actions-section .html-content li::before {
|
||||
content: '▸';
|
||||
color: var(--action-color);
|
||||
}
|
||||
|
||||
.no-content {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #5f6368;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📖 精读:深度分析报告</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {{ user_name }}</span>
|
||||
<span><strong>分析时间:</strong> {{ current_date_time_str }}</span>
|
||||
<span><strong>星期:</strong> {{ current_weekday }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section summary-section">
|
||||
<h2><span class="icon">📝</span>详细摘要</h2>
|
||||
<div class="html-content">{{ summary_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section keypoints-section">
|
||||
<h2><span class="icon">💡</span>关键信息点</h2>
|
||||
<div class="html-content">{{ keypoints_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section actions-section">
|
||||
<h2><span class="icon">🎯</span>行动建议</h2>
|
||||
<div class="html-content">{{ actions_html | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{ current_year }} 精读 - 深度文本分析服务</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200, description="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。"
|
||||
)
|
||||
RECOMMENDED_MIN_LENGTH: int = Field(
|
||||
default=500, description="建议的最小文本长度,以获得最佳分析效果。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""
|
||||
解析LLM的Markdown输出,将其转换为HTML片段。
|
||||
"""
|
||||
summary_match = re.search(
|
||||
r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
keypoints_match = re.search(
|
||||
r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
actions_match = re.search(
|
||||
r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
|
||||
summary_md = summary_match.group(1).strip() if summary_match else ""
|
||||
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
|
||||
actions_md = actions_match.group(1).strip() if actions_match else ""
|
||||
|
||||
if not any([summary_md, keypoints_md, actions_md]):
|
||||
summary_md = llm_output.strip()
|
||||
logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。")
|
||||
|
||||
# 使用 'nl2br' 扩展将换行符 \n 转换为 <br>
|
||||
md_extensions = ["nl2br"]
|
||||
summary_html = (
|
||||
markdown.markdown(summary_md, extensions=md_extensions)
|
||||
if summary_md
|
||||
else '<p class="no-content">未能提取摘要信息。</p>'
|
||||
)
|
||||
keypoints_html = (
|
||||
markdown.markdown(keypoints_md, extensions=md_extensions)
|
||||
if keypoints_md
|
||||
else '<p class="no-content">未能提取关键信息点。</p>'
|
||||
)
|
||||
actions_html = (
|
||||
markdown.markdown(actions_md, extensions=md_extensions)
|
||||
if actions_md
|
||||
else '<p class="no-content">暂无明确的行动建议。</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"summary_html": summary_html,
|
||||
"keypoints_html": keypoints_html,
|
||||
"actions_html": actions_html,
|
||||
}
|
||||
|
||||
def _build_html(self, context: dict) -> str:
|
||||
"""
|
||||
使用 Jinja2 模板和上下文数据构建最终的HTML内容。
|
||||
"""
|
||||
template = Template(HTML_TEMPLATE)
|
||||
return template.render(context)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday = now.strftime("%A")
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages or not messages[-1].get("content"):
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
|
||||
original_content = messages[-1]["content"]
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
# Recommend for longer texts
|
||||
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "📖 精读已启动,正在进行深度分析...",
|
||||
},
|
||||
}
|
||||
)
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "📖 精读: 深入分析文本,提炼精华...",
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象, 用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
|
||||
processed_content = self._process_llm_output(assistant_response_content)
|
||||
|
||||
context = {
|
||||
"user_language": user_language,
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"current_weekday": current_weekday,
|
||||
"current_year": current_year,
|
||||
**processed_content,
|
||||
}
|
||||
|
||||
final_html_content = self._build_html(context)
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "📖 精读: 分析完成!", "done": True},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"📖 精读完成,{user_name}!深度分析报告已生成。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"精读处理失败: {str(e)}"
|
||||
logger.error(f"精读错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "精读: 处理失败。",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"精读处理失败, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
45
plugins/filters/README.md
Normal file
45
plugins/filters/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Filters
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
Filters process and modify user input before it is sent to the LLM. This directory contains various filters that can be used to extend OpenWebUI functionality.
|
||||
|
||||
## 📋 Filter List
|
||||
|
||||
| Filter Name | Description | Documentation |
|
||||
| :--- | :--- | :--- |
|
||||
| **Async Context Compression** | Reduces token consumption in long conversations through intelligent summarization and message compression while maintaining conversational coherence. | [English](./async-context-compression/async_context_compression.md) / [中文](./async-context-compression/async_context_compression_cn.md) |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installing a Filter
|
||||
|
||||
1. Navigate to the desired filter directory
|
||||
2. Download the corresponding `.py` file to your local machine
|
||||
3. Open OpenWebUI Admin Settings and find the "Filters" section
|
||||
4. Upload the Python file
|
||||
5. Configure the filter parameters according to its documentation
|
||||
6. Refresh the page and enable the filter in your chat settings
|
||||
|
||||
## 📖 Development Guide
|
||||
|
||||
When adding a new filter, please follow these steps:
|
||||
|
||||
1. **Create Filter Directory**: Create a new folder in the current directory (e.g., `my_filter/`)
|
||||
2. **Write Filter Code**: Create a `.py` file with clear documentation of functionality and configuration in comments
|
||||
3. **Write Documentation**:
|
||||
- Create `filter_name.md` (English version)
|
||||
- Create `filter_name_cn.md` (Chinese version)
|
||||
- Documentation should include: feature description, configuration parameters, usage examples, and troubleshooting
|
||||
4. **Update This List**: Add your new filter to the table above
|
||||
|
||||
## ⚙️ Configuration Best Practices
|
||||
|
||||
- **Priority Management**: Set appropriate filter priority to ensure correct execution order
|
||||
- **Parameter Tuning**: Adjust filter parameters based on your specific needs
|
||||
- **Debug Logging**: Enable debug mode during development, disable in production
|
||||
- **Performance Testing**: Test filter performance under high load
|
||||
|
||||
---
|
||||
|
||||
> **Contributor Note**: To ensure project maintainability and user experience, please provide clear and complete documentation for each new filter, including feature description, parameter configuration, usage examples, and troubleshooting guide.
|
||||
67
plugins/filters/README_CN.md
Normal file
67
plugins/filters/README_CN.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 自动上下文合并过滤器 (Auto Context Merger Filter)
|
||||
|
||||
## 概述
|
||||
|
||||
`auto_context_merger` 是一个 Open WebUI 过滤器插件,旨在通过自动收集和注入上一回合多模型回答的上下文,来增强后续对话的连贯性和深度。当用户在一次多模型回答之后提出新的后续问题时,此过滤器会自动激活。
|
||||
|
||||
它会从对话历史中识别出上一回合所有 AI 模型的回答,将它们按照清晰的格式直接拼接起来,然后作为一个系统消息注入到当前请求中。这样,当前模型在处理用户的新问题时,就能直接参考到之前所有 AI 的观点,从而提供更全面、更连贯的回答。
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **触发时机**: 当用户在一次“多模型回答”之后,发送新的后续问题时,此过滤器会自动激活。
|
||||
2. **获取历史数据**: 过滤器会使用当前对话的 `chat_id`,从数据库中加载完整的对话历史记录。
|
||||
3. **分析上一回合**: 通过分析对话树结构,它能准确找到用户上一个问题,以及当时所有 AI 模型给出的并行回答。
|
||||
4. **直接格式化**: 如果检测到上一回合确实有多个 AI 回答,它会收集所有这些 AI 的回答内容。
|
||||
5. **智能注入**: 将这些格式化后的回答作为一个系统消息,注入到当前请求的 `messages` 列表的开头,紧邻用户的新问题之前。
|
||||
6. **传递给目标模型**: 修改后的消息体(包含格式化后的上下文)将传递给用户最初选择的目标模型。目标模型在生成响应时,将能够利用这个更丰富的上下文。
|
||||
7. **状态更新**: 在整个处理过程中,过滤器会通过 `__event_emitter__` 提供实时状态更新,让用户了解处理进度。
|
||||
|
||||
## 配置 (Valves)
|
||||
|
||||
您可以在 Open WebUI 的管理界面中配置此过滤器的 `Valves`。
|
||||
|
||||
* **`CONTEXT_PREFIX`** (字符串, 必填):
|
||||
* **描述**: 注入的系统消息的前缀文本。它会出现在合并后的上下文之前,用于向模型解释这段内容的来源和目的。
|
||||
* **示例**: `**背景知识**:为了更好地回答您的新问题,请参考上一轮对话中多个AI模型给出的回答:\n\n`
|
||||
|
||||
## 如何使用
|
||||
|
||||
1. **部署过滤器**: 将 `auto_context_merger.py` 文件放置在 Open WebUI 实例的 `plugins/filters/` 目录下。
|
||||
2. **启用过滤器**: 登录 Open WebUI 管理界面,导航到 **Workspace -> Functions**。找到 `auto_context_merger` 过滤器并启用它。
|
||||
3. **配置参数**: 点击 `auto_context_merger` 过滤器旁边的编辑按钮,根据您的需求配置 `CONTEXT_PREFIX`。
|
||||
4. **开始对话**:
|
||||
* 首先,向一个模型提问,并确保有多个模型(例如通过 `gemini_manifold` 或其他多模型工具)给出回答。
|
||||
* 然后,针对这个多模型回答,提出您的后续问题。
|
||||
* 此过滤器将自动激活,将上一回合所有 AI 的回答合并并注入到当前请求中。
|
||||
|
||||
## 示例
|
||||
|
||||
假设您配置了 `CONTEXT_PREFIX` 为默认值。
|
||||
|
||||
1. **用户提问**: “解释一下量子力学”
|
||||
2. **多个 AI 回答** (例如,模型 A 和模型 B 都给出了回答)
|
||||
3. **用户再次提问**: “那么,量子纠缠和量子隧穿有什么区别?”
|
||||
|
||||
此时,`auto_context_merger` 过滤器将自动激活:
|
||||
1. 它会获取模型 A 和模型 B 对“解释一下量子力学”的回答。
|
||||
2. 将它们格式化为:
|
||||
```
|
||||
**背景知识**:为了更好地回答您的新问题,请参考上一轮对话中多个AI模型给出的回答:
|
||||
|
||||
**来自模型 '模型A名称' 的回答是:**
|
||||
[模型A对量子力学的解释]
|
||||
|
||||
---
|
||||
|
||||
**来自模型 '模型B名称' 的回答是:**
|
||||
[模型B对量子力学的解释]
|
||||
```
|
||||
3. 然后,将这段内容作为一个系统消息,注入到当前请求中,紧邻“那么,量子纠缠和量子隧穿有什么区别?”这个用户问题之前。
|
||||
|
||||
最终,模型将收到一个包含所有相关上下文的请求,从而能够更准确、更全面地回答您的后续问题。
|
||||
|
||||
## 注意事项
|
||||
|
||||
* 此过滤器旨在增强多模型对话的连贯性,通过提供更丰富的上下文来帮助模型理解后续问题。
|
||||
* 确保您的 Open WebUI 实例中已配置并启用了 `gemini_manifold` 或其他能够产生多模型回答的工具,以便此过滤器能够检测到多模型历史。
|
||||
* 此过滤器不会增加额外的模型调用,因此不会显著增加延迟或成本。它只是对现有历史数据进行格式化和注入。
|
||||
@@ -0,0 +1,77 @@
|
||||
# Async Context Compression Filter
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.0.0 | **License:** MIT
|
||||
|
||||
> **Important Note**: To ensure the maintainability and usability of all filters, each filter should be accompanied by clear and complete documentation to fully explain its functionality, configuration, and usage.
|
||||
|
||||
This filter significantly reduces token consumption in long conversations by using intelligent summarization and message compression, while maintaining conversational coherence.
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
- ✅ **Automatic Compression**: Triggers context compression automatically based on a message count threshold.
|
||||
- ✅ **Asynchronous Summarization**: Generates summaries in the background without blocking the current chat response.
|
||||
- ✅ **Persistent Storage**: Supports both PostgreSQL and SQLite databases to ensure summaries are not lost after a service restart.
|
||||
- ✅ **Flexible Retention Policy**: Freely configure the number of initial and final messages to keep, ensuring critical information and context continuity.
|
||||
- ✅ **Smart Injection**: Intelligently injects the generated historical summary into the new context.
|
||||
|
||||
---
|
||||
|
||||
## Installation & Configuration
|
||||
|
||||
### 1. Environment Variable
|
||||
|
||||
This plugin requires a database connection. You **must** configure the `DATABASE_URL` in your Open WebUI environment variables.
|
||||
|
||||
- **PostgreSQL Example**:
|
||||
```
|
||||
DATABASE_URL=postgresql://user:password@host:5432/openwebui
|
||||
```
|
||||
- **SQLite Example**:
|
||||
```
|
||||
DATABASE_URL=sqlite:///path/to/your/data/webui.db
|
||||
```
|
||||
|
||||
### 2. Filter Order
|
||||
|
||||
It is recommended to set the priority of this filter relatively high (a smaller number) to ensure it runs before other filters that might modify message content. A typical order might be:
|
||||
|
||||
1. **Pre-Filters (priority < 10)**
|
||||
- e.g., A filter that injects a system-level prompt.
|
||||
2. **This Compression Filter (priority = 10)**
|
||||
3. **Post-Filters (priority > 10)**
|
||||
- e.g., A filter that formats the final output.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
You can adjust the following parameters in the filter's settings:
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `priority` | `10` | The execution order of the filter. Lower numbers run first. |
|
||||
| `compression_threshold` | `15` | When the total message count reaches this value, a background summary generation will be triggered. |
|
||||
| `keep_first` | `1` | Always keep the first N messages. The first message often contains important system prompts. |
|
||||
| `keep_last` | `6` | Always keep the last N messages to ensure contextual coherence. |
|
||||
| `summary_model` | `None` | The model used for generating summaries. **Strongly recommended** to set a fast, economical, and compatible model (e.g., `gemini-2.5-flash`). If left empty, it will try to use the current chat's model, which may fail if it's an incompatible model type (like a Pipe model). |
|
||||
| `max_summary_tokens` | `4000` | The maximum number of tokens allowed for the generated summary. |
|
||||
| `summary_temperature` | `0.3` | Controls the randomness of the summary. Lower values are more deterministic. |
|
||||
| `debug_mode` | `true` | Whether to print detailed debug information to the log. Recommended to set to `false` in production. |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Problem: Database connection failed.**
|
||||
- **Solution**: Please ensure the `DATABASE_URL` environment variable is set correctly and that the database service is running.
|
||||
|
||||
- **Problem: Summary not generated.**
|
||||
- **Solution**: Check if the `compression_threshold` has been met and verify that `summary_model` is configured correctly. Check the logs for detailed errors.
|
||||
|
||||
- **Problem: Initial system prompt is lost.**
|
||||
- **Solution**: Ensure `keep_first` is set to a value greater than 0 to preserve the initial messages containing important information.
|
||||
|
||||
- **Problem: Compression effect is not significant.**
|
||||
- **Solution**: Try increasing the `compression_threshold` or decreasing the `keep_first` / `keep_last` values.
|
||||
@@ -0,0 +1,780 @@
|
||||
"""
|
||||
title: Async Context Compression
|
||||
id: async_context_compression
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
description: Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.
|
||||
version: 1.0.1
|
||||
license: MIT
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
📌 Overview
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
This filter significantly reduces token consumption in long conversations by using intelligent summarization and message compression, while maintaining conversational coherence.
|
||||
|
||||
Core Features:
|
||||
✅ Automatic compression triggered by a message count threshold
|
||||
✅ Asynchronous summary generation (does not block user response)
|
||||
✅ Persistent storage with database support (PostgreSQL and SQLite)
|
||||
✅ Flexible retention policy (configurable to keep first and last N messages)
|
||||
✅ Smart summary injection to maintain context
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🔄 Workflow
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Phase 1: Inlet (Pre-request processing)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
1. Receives all messages in the current conversation.
|
||||
2. Checks for a previously saved summary.
|
||||
3. If a summary exists and the message count exceeds the retention threshold:
|
||||
├─ Extracts the first N messages to be kept.
|
||||
├─ Injects the summary into the first message.
|
||||
├─ Extracts the last N messages to be kept.
|
||||
└─ Combines them into a new message list: [Kept First Messages + Summary] + [Kept Last Messages].
|
||||
4. Sends the compressed message list to the LLM.
|
||||
|
||||
Phase 2: Outlet (Post-response processing)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
1. Triggered after the LLM response is complete.
|
||||
2. Checks if the message count has reached the compression threshold.
|
||||
3. If the threshold is met, an asynchronous background task is started to generate a summary:
|
||||
├─ Extracts messages to be summarized (excluding the kept first and last messages).
|
||||
├─ Calls the LLM to generate a concise summary.
|
||||
└─ Saves the summary to the database.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
💾 Storage
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
This filter uses a database for persistent storage, configured via the `DATABASE_URL` environment variable. It supports both PostgreSQL and SQLite.
|
||||
|
||||
Configuration:
|
||||
- The `DATABASE_URL` environment variable must be set.
|
||||
- PostgreSQL Example: `postgresql://user:password@host:5432/openwebui`
|
||||
- SQLite Example: `sqlite:///path/to/your/database.db`
|
||||
|
||||
The filter automatically selects the appropriate database driver based on the `DATABASE_URL` prefix (`postgres` or `sqlite`).
|
||||
|
||||
Table Structure (`chat_summary`):
|
||||
- id: Primary Key (auto-increment)
|
||||
- chat_id: Unique chat identifier (indexed)
|
||||
- summary: The summary content (TEXT)
|
||||
- compressed_message_count: The original number of messages
|
||||
- created_at: Timestamp of creation
|
||||
- updated_at: Timestamp of last update
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
📊 Compression Example
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Scenario: A 20-message conversation (Default settings: keep first 1, keep last 6)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Before Compression:
|
||||
Message 1: [Initial prompt + First question]
|
||||
Messages 2-14: [Historical conversation]
|
||||
Messages 15-20: [Recent conversation]
|
||||
Total: 20 full messages
|
||||
|
||||
After Compression:
|
||||
Message 1: [Initial prompt + Historical summary + First question]
|
||||
Messages 15-20: [Last 6 full messages]
|
||||
Total: 7 messages
|
||||
|
||||
Effect:
|
||||
✓ Saves 13 messages (approx. 65%)
|
||||
✓ Retains full context
|
||||
✓ Protects important initial prompts
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
⚙️ Configuration
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
priority
|
||||
Default: 10
|
||||
Description: The execution order of the filter. Lower numbers run first.
|
||||
|
||||
compression_threshold
|
||||
Default: 15
|
||||
Description: When the message count reaches this value, a background summary generation will be triggered after the conversation ends.
|
||||
Recommendation: Adjust based on your model's context window and cost.
|
||||
|
||||
keep_first
|
||||
Default: 1
|
||||
Description: Always keep the first N messages of the conversation. Set to 0 to disable. The first message often contains important system prompts.
|
||||
|
||||
keep_last
|
||||
Default: 6
|
||||
Description: Always keep the last N full messages of the conversation to ensure context coherence.
|
||||
|
||||
summary_model
|
||||
Default: None
|
||||
Description: The LLM used to generate the summary.
|
||||
Recommendation:
|
||||
- It is strongly recommended to configure a fast, economical, and compatible model, such as `deepseek-v3`、`gemini-2.5-flash`、`gpt-4.1`。
|
||||
- If left empty, the filter will attempt to use the model from the current conversation.
|
||||
Note:
|
||||
- If the current conversation uses a pipeline (Pipe) model or a model that does not support standard generation APIs, leaving this field empty may cause summary generation to fail. In this case, you must specify a valid model.
|
||||
|
||||
max_summary_tokens
|
||||
Default: 4000
|
||||
Description: The maximum number of tokens allowed for the generated summary.
|
||||
|
||||
summary_temperature
|
||||
Default: 0.3
|
||||
Description: Controls the randomness of the summary generation. Lower values produce more deterministic output.
|
||||
|
||||
debug_mode
|
||||
Default: true
|
||||
Description: Prints detailed debug information to the log. Recommended to set to `false` in production.
|
||||
|
||||
🔧 Deployment
|
||||
═══════════════════════════════════════════════════════
|
||||
|
||||
Docker Compose Example:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
services:
|
||||
openwebui:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/openwebui
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: openwebui
|
||||
|
||||
Suggested Filter Installation Order:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
It is recommended to set the priority of this filter relatively high (a smaller number) to ensure it runs before other filters that might modify message content. A typical order might be:
|
||||
|
||||
1. Filters that need access to the full, uncompressed history (priority < 10)
|
||||
(e.g., a filter that injects a system-level prompt)
|
||||
2. This compression filter (priority = 10)
|
||||
3. Filters that run after compression (priority > 10)
|
||||
(e.g., a final output formatting filter)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
📝 Database Query Examples
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
View all summaries:
|
||||
SELECT
|
||||
chat_id,
|
||||
LEFT(summary, 100) as summary_preview,
|
||||
compressed_message_count,
|
||||
updated_at
|
||||
FROM chat_summary
|
||||
ORDER BY updated_at DESC;
|
||||
|
||||
Query a specific conversation:
|
||||
SELECT *
|
||||
FROM chat_summary
|
||||
WHERE chat_id = 'your_chat_id';
|
||||
|
||||
Delete old summaries:
|
||||
DELETE FROM chat_summary
|
||||
WHERE updated_at < NOW() - INTERVAL '30 days';
|
||||
|
||||
Statistics:
|
||||
SELECT
|
||||
COUNT(*) as total_summaries,
|
||||
AVG(LENGTH(summary)) as avg_summary_length,
|
||||
AVG(compressed_message_count) as avg_msg_count
|
||||
FROM chat_summary;
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
⚠️ Important Notes
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
1. Database Permissions
|
||||
⚠ Ensure the user specified in `DATABASE_URL` has permissions to create tables.
|
||||
⚠ The `chat_summary` table will be created automatically on first run.
|
||||
|
||||
2. Retention Policy
|
||||
⚠ The `keep_first` setting is crucial for preserving initial messages that contain system prompts. Configure it as needed.
|
||||
|
||||
3. Performance
|
||||
⚠ Summary generation is asynchronous and will not block the user response.
|
||||
⚠ There will be a brief background processing time when the threshold is first met.
|
||||
|
||||
4. Cost Optimization
|
||||
⚠ The summary model is called once each time the threshold is met.
|
||||
⚠ Set `compression_threshold` reasonably to avoid frequent calls.
|
||||
⚠ It's recommended to use a fast and economical model to generate summaries.
|
||||
|
||||
5. Multimodal Support
|
||||
✓ This filter supports multimodal messages containing images.
|
||||
✓ The summary is generated only from the text content.
|
||||
✓ Non-text parts (like images) are preserved in their original messages during compression.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
🐛 Troubleshooting
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Problem: Database connection failed
|
||||
Solution:
|
||||
1. Verify that the `DATABASE_URL` environment variable is set correctly.
|
||||
2. Confirm that `DATABASE_URL` starts with either `sqlite` or `postgres`.
|
||||
3. Ensure the database service is running and network connectivity is normal.
|
||||
4. Validate the username, password, host, and port in the connection URL.
|
||||
5. Check the Open WebUI container logs for detailed error messages.
|
||||
|
||||
Problem: Summary not generated
|
||||
Solution:
|
||||
1. Check if the `compression_threshold` has been met.
|
||||
2. Verify that the `summary_model` is configured correctly.
|
||||
3. Check the debug logs for any error messages.
|
||||
|
||||
Problem: Initial system prompt is lost
|
||||
Solution:
|
||||
- Ensure `keep_first` is set to a value greater than 0 to preserve the initial messages containing this information.
|
||||
|
||||
Problem: Compression effect is not significant
|
||||
Solution:
|
||||
1. Increase the `compression_threshold` appropriately.
|
||||
2. Decrease the number of `keep_last` or `keep_first`.
|
||||
3. Check if the conversation is actually long enough.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
# Open WebUI built-in imports
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
from fastapi.requests import Request
|
||||
from open_webui.main import app as webui_app
|
||||
|
||||
# Database imports
|
||||
from sqlalchemy import create_engine, Column, String, Text, DateTime, Integer
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class ChatSummary(Base):
|
||||
"""Chat Summary Storage Table"""
|
||||
|
||||
__tablename__ = "chat_summary"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
chat_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
summary = Column(Text, nullable=False)
|
||||
compressed_message_count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class Filter:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self._db_engine = None
|
||||
self._SessionLocal = None
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""Initializes the database connection and table."""
|
||||
try:
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
|
||||
if not database_url:
|
||||
print("[Database] ❌ Error: DATABASE_URL environment variable is not set. Please set this variable.")
|
||||
self._db_engine = None
|
||||
self._SessionLocal = None
|
||||
return
|
||||
|
||||
db_type = None
|
||||
engine_args = {}
|
||||
|
||||
if database_url.startswith("sqlite"):
|
||||
db_type = "SQLite"
|
||||
engine_args = {
|
||||
"connect_args": {"check_same_thread": False},
|
||||
"echo": False,
|
||||
}
|
||||
elif database_url.startswith("postgres"):
|
||||
db_type = "PostgreSQL"
|
||||
if database_url.startswith("postgres://"):
|
||||
database_url = database_url.replace(
|
||||
"postgres://", "postgresql://", 1
|
||||
)
|
||||
print("[Database] ℹ️ Automatically converted postgres:// to postgresql://")
|
||||
engine_args = {
|
||||
"pool_pre_ping": True,
|
||||
"pool_recycle": 3600,
|
||||
"echo": False,
|
||||
}
|
||||
else:
|
||||
print(
|
||||
f"[Database] ❌ Error: Unsupported database type. DATABASE_URL must start with 'sqlite' or 'postgres'. Current value: {database_url}"
|
||||
)
|
||||
self._db_engine = None
|
||||
self._SessionLocal = None
|
||||
return
|
||||
|
||||
# Create database engine
|
||||
self._db_engine = create_engine(database_url, **engine_args)
|
||||
|
||||
# Create session factory
|
||||
self._SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=self._db_engine
|
||||
)
|
||||
|
||||
# Create table if it doesn't exist
|
||||
Base.metadata.create_all(bind=self._db_engine)
|
||||
|
||||
print(f"[Database] ✅ Successfully connected to {db_type} and initialized the chat_summary table.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Database] ❌ Initialization failed: {str(e)}")
|
||||
self._db_engine = None
|
||||
self._SessionLocal = None
|
||||
|
||||
class Valves(BaseModel):
|
||||
priority: int = Field(
|
||||
default=10, description="Priority level for the filter operations."
|
||||
)
|
||||
compression_threshold: int = Field(
|
||||
default=15, ge=0, description="The number of messages at which to trigger compression."
|
||||
)
|
||||
keep_first: int = Field(
|
||||
default=1, ge=0, description="Always keep the first N messages. Set to 0 to disable."
|
||||
)
|
||||
keep_last: int = Field(default=6, ge=0, description="Always keep the last N messages.")
|
||||
summary_model: str = Field(
|
||||
default=None,
|
||||
description="The model to use for generating the summary. If empty, uses the current conversation's model.",
|
||||
)
|
||||
max_summary_tokens: int = Field(
|
||||
default=4000, ge=1, description="The maximum number of tokens for the summary."
|
||||
)
|
||||
summary_temperature: float = Field(
|
||||
default=0.3, ge=0.0, le=2.0, description="The temperature for summary generation."
|
||||
)
|
||||
debug_mode: bool = Field(default=True, description="Enable detailed logging for debugging.")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_thresholds(self) -> "Valves":
|
||||
kept_count = self.keep_first + self.keep_last
|
||||
if self.compression_threshold <= kept_count:
|
||||
raise ValueError(
|
||||
f"compression_threshold ({self.compression_threshold}) must be greater than "
|
||||
f"the sum of keep_first ({self.keep_first}) and keep_last ({self.keep_last}) ({kept_count})."
|
||||
)
|
||||
return self
|
||||
|
||||
def _save_summary(self, chat_id: str, summary: str, body: dict):
|
||||
"""Saves the summary to the database."""
|
||||
if not self._SessionLocal:
|
||||
if self.valves.debug_mode:
|
||||
print("[Storage] Database not initialized, skipping summary save.")
|
||||
return
|
||||
|
||||
try:
|
||||
session = self._SessionLocal()
|
||||
try:
|
||||
# Find existing record
|
||||
existing = (
|
||||
session.query(ChatSummary).filter_by(chat_id=chat_id).first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing record
|
||||
existing.summary = summary
|
||||
existing.compressed_message_count = len(body.get("messages", []))
|
||||
existing.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new record
|
||||
new_summary = ChatSummary(
|
||||
chat_id=chat_id,
|
||||
summary=summary,
|
||||
compressed_message_count=len(body.get("messages", [])),
|
||||
)
|
||||
session.add(new_summary)
|
||||
|
||||
session.commit()
|
||||
|
||||
if self.valves.debug_mode:
|
||||
action = "Updated" if existing else "Created"
|
||||
print(f"[Storage] Summary has been {action.lower()} in the database (Chat ID: {chat_id})")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Storage] ❌ Database save failed: {str(e)}")
|
||||
|
||||
def _load_summary(self, chat_id: str, body: dict) -> Optional[str]:
|
||||
"""Loads the summary from the database."""
|
||||
if not self._SessionLocal:
|
||||
if self.valves.debug_mode:
|
||||
print("[Storage] Database not initialized, cannot load summary.")
|
||||
return None
|
||||
|
||||
try:
|
||||
session = self._SessionLocal()
|
||||
try:
|
||||
record = (
|
||||
session.query(ChatSummary).filter_by(chat_id=chat_id).first()
|
||||
)
|
||||
|
||||
if record:
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Storage] Loaded summary from database (Chat ID: {chat_id})")
|
||||
print(
|
||||
f"[Storage] Last updated: {record.updated_at}, Original message count: {record.compressed_message_count}"
|
||||
)
|
||||
return record.summary
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Storage] ❌ Database read failed: {str(e)}")
|
||||
|
||||
return None
|
||||
|
||||
def _inject_summary_to_first_message(self, message: dict, summary: str) -> dict:
|
||||
"""Injects the summary into the first message by prepending it."""
|
||||
content = message.get("content", "")
|
||||
summary_block = f"【Historical Conversation Summary】\n{summary}\n\n---\nBelow is the recent conversation:\n\n"
|
||||
|
||||
# Handle different content types
|
||||
if isinstance(content, list): # Multimodal content
|
||||
# Find the first text part and insert the summary before it
|
||||
new_content = []
|
||||
summary_inserted = False
|
||||
|
||||
for part in content:
|
||||
if (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and not summary_inserted
|
||||
):
|
||||
# Prepend summary to the first text part
|
||||
new_content.append(
|
||||
{"type": "text", "text": summary_block + part.get("text", "")}
|
||||
)
|
||||
summary_inserted = True
|
||||
else:
|
||||
new_content.append(part)
|
||||
|
||||
# If no text part, insert at the beginning
|
||||
if not summary_inserted:
|
||||
new_content.insert(0, {"type": "text", "text": summary_block})
|
||||
|
||||
message["content"] = new_content
|
||||
|
||||
elif isinstance(content, str): # Plain text
|
||||
message["content"] = summary_block + content
|
||||
|
||||
return message
|
||||
|
||||
async def inlet(
|
||||
self, body: dict, __user__: Optional[dict] = None, __metadata__: dict = None
|
||||
) -> dict:
|
||||
"""
|
||||
Executed before sending to the LLM.
|
||||
Compression Strategy:
|
||||
1. Keep the first N messages.
|
||||
2. Inject the summary into the first message (if keep_first > 0).
|
||||
3. Keep the last N messages.
|
||||
"""
|
||||
messages = body.get("messages", [])
|
||||
chat_id = __metadata__["chat_id"]
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[Inlet] Chat ID: {chat_id}")
|
||||
print(f"[Inlet] Received {len(messages)} messages")
|
||||
|
||||
# [Optimization] Load summary in a background thread to avoid blocking the event loop.
|
||||
if self.valves.debug_mode:
|
||||
print("[Optimization] Loading summary in a background thread to avoid blocking the event loop.")
|
||||
saved_summary = await asyncio.to_thread(self._load_summary, chat_id, body)
|
||||
|
||||
total_kept_count = self.valves.keep_first + self.valves.keep_last
|
||||
|
||||
if saved_summary and len(messages) > total_kept_count:
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Inlet] Found saved summary, applying compression.")
|
||||
|
||||
first_messages_to_keep = []
|
||||
|
||||
if self.valves.keep_first > 0:
|
||||
# Copy the initial messages to keep
|
||||
first_messages_to_keep = [
|
||||
m.copy() for m in messages[: self.valves.keep_first]
|
||||
]
|
||||
# Inject the summary into the very first message
|
||||
first_messages_to_keep[0] = self._inject_summary_to_first_message(
|
||||
first_messages_to_keep[0], saved_summary
|
||||
)
|
||||
else:
|
||||
# If not keeping initial messages, create a new system message for the summary
|
||||
summary_block = (
|
||||
f"【Historical Conversation Summary】\n{saved_summary}\n\n---\nBelow is the recent conversation:\n\n"
|
||||
)
|
||||
first_messages_to_keep.append(
|
||||
{"role": "system", "content": summary_block}
|
||||
)
|
||||
|
||||
# Keep the last messages
|
||||
last_messages_to_keep = (
|
||||
messages[-self.valves.keep_last :] if self.valves.keep_last > 0 else []
|
||||
)
|
||||
|
||||
# Combine: [Kept initial messages (with summary)] + [Kept recent messages]
|
||||
body["messages"] = first_messages_to_keep + last_messages_to_keep
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Inlet] ✂️ Compression complete:")
|
||||
print(f" - Original messages: {len(messages)}")
|
||||
print(f" - Compressed to: {len(body['messages'])}")
|
||||
print(
|
||||
f" - Structure: [Keep first {self.valves.keep_first} (with summary)] + [Keep last {self.valves.keep_last}]"
|
||||
)
|
||||
print(f" - Saved: {len(messages) - len(body['messages'])} messages")
|
||||
else:
|
||||
if self.valves.debug_mode:
|
||||
if not saved_summary:
|
||||
print(f"[Inlet] No summary found, using full conversation history.")
|
||||
else:
|
||||
print(f"[Inlet] Message count does not exceed retention threshold, no compression applied.")
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
return body
|
||||
|
||||
async def outlet(
|
||||
self, body: dict, __user__: Optional[dict] = None, __metadata__: dict = None
|
||||
) -> dict:
|
||||
"""
|
||||
Executed after the LLM response is complete.
|
||||
Triggers summary generation asynchronously.
|
||||
"""
|
||||
messages = body.get("messages", [])
|
||||
chat_id = __metadata__["chat_id"]
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[Outlet] Chat ID: {chat_id}")
|
||||
print(f"[Outlet] Response complete, current message count: {len(messages)}")
|
||||
|
||||
# Check if compression is needed
|
||||
if len(messages) >= self.valves.compression_threshold:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[Outlet] ⚡ Compression threshold reached ({len(messages)} >= {self.valves.compression_threshold})"
|
||||
)
|
||||
print(f"[Outlet] Preparing to generate summary in the background...")
|
||||
|
||||
# Generate summary asynchronously in the background
|
||||
asyncio.create_task(
|
||||
self._generate_summary_async(messages, chat_id, body, __user__)
|
||||
)
|
||||
else:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[Outlet] Compression threshold not reached ({len(messages)} < {self.valves.compression_threshold})"
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
return body
|
||||
|
||||
async def _generate_summary_async(
|
||||
self, messages: list, chat_id: str, body: dict, user_data: Optional[dict]
|
||||
):
|
||||
"""
|
||||
Generates a summary asynchronously in the background.
|
||||
"""
|
||||
try:
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n[🤖 Async Summary Task] Starting...")
|
||||
|
||||
# Messages to summarize: exclude kept initial and final messages
|
||||
if self.valves.keep_last > 0:
|
||||
messages_to_summarize = messages[
|
||||
self.valves.keep_first : -self.valves.keep_last
|
||||
]
|
||||
else:
|
||||
messages_to_summarize = messages[self.valves.keep_first :]
|
||||
|
||||
if len(messages_to_summarize) == 0:
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 Async Summary Task] No messages to summarize, skipping.")
|
||||
return
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 Async Summary Task] Preparing to summarize {len(messages_to_summarize)} messages.")
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Protecting: First {self.valves.keep_first} + Last {self.valves.keep_last} messages."
|
||||
)
|
||||
|
||||
# Build conversation history text
|
||||
conversation_text = self._format_messages_for_summary(messages_to_summarize)
|
||||
|
||||
# Call LLM to generate summary
|
||||
summary = await self._call_summary_llm(conversation_text, body, user_data)
|
||||
|
||||
# [Optimization] Save summary in a background thread to avoid blocking the event loop.
|
||||
if self.valves.debug_mode:
|
||||
print("[Optimization] Saving summary in a background thread to avoid blocking the event loop.")
|
||||
await asyncio.to_thread(self._save_summary, chat_id, summary, body)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 Async Summary Task] ✅ Complete! Summary length: {len(summary)} characters.")
|
||||
print(f"[🤖 Async Summary Task] Summary preview: {summary[:150]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[🤖 Async Summary Task] ❌ Error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
# Save a simple placeholder even on failure
|
||||
fallback_summary = (
|
||||
f"[Historical Conversation Summary] Contains content from approximately {len(messages_to_summarize)} messages."
|
||||
)
|
||||
|
||||
# [Optimization] Save summary in a background thread to avoid blocking the event loop.
|
||||
if self.valves.debug_mode:
|
||||
print("[Optimization] Saving summary in a background thread to avoid blocking the event loop.")
|
||||
await asyncio.to_thread(self._save_summary, chat_id, fallback_summary, body)
|
||||
|
||||
def _format_messages_for_summary(self, messages: list) -> str:
|
||||
"""Formats messages for summarization."""
|
||||
formatted = []
|
||||
for i, msg in enumerate(messages, 1):
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Handle multimodal content
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
content = " ".join(text_parts)
|
||||
|
||||
# Handle role name
|
||||
role_name = {"user": "User", "assistant": "Assistant"}.get(role, role)
|
||||
|
||||
# Limit length of each message to avoid excessive length
|
||||
if len(content) > 500:
|
||||
content = content[:500] + "..."
|
||||
|
||||
formatted.append(f"[{i}] {role_name}: {content}")
|
||||
|
||||
return "\n\n".join(formatted)
|
||||
|
||||
async def _call_summary_llm(
|
||||
self, conversation_text: str, body: dict, user_data: dict
|
||||
) -> str:
|
||||
"""
|
||||
Calls the LLM to generate a summary using Open WebUI's built-in method.
|
||||
"""
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] Using Open WebUI's built-in method.")
|
||||
|
||||
# Build summary prompt
|
||||
summary_prompt = f"""
|
||||
You are a professional conversation context compression assistant. Your task is to perform a high-fidelity compression of the [Conversation Content] below, producing a concise summary that can be used directly as context for subsequent conversation. Strictly adhere to the following requirements:
|
||||
|
||||
MUST RETAIN: Topics/goals, user intent, key facts and data, important parameters and constraints, deadlines, decisions/conclusions, action items and their status, and technical details like code/commands (code must be preserved as is).
|
||||
REMOVE: Greetings, politeness, repetitive statements, off-topic chatter, and procedural details (unless essential). For information that has been overturned or is outdated, please mark it as "Obsolete: <explanation>" when retaining.
|
||||
CONFLICT RESOLUTION: If there are contradictions or multiple revisions, retain the latest consistent conclusion and list unresolved or conflicting points under "Points to Clarify".
|
||||
STRUCTURE AND TONE: Output in structured bullet points. Be logical, objective, and concise. Summarize from a third-person perspective. Use code blocks to preserve technical/code snippets verbatim.
|
||||
OUTPUT LENGTH: Strictly limit the summary content to within {int(self.valves.max_summary_tokens * 3)} characters. Prioritize key information; if space is insufficient, trim details rather than core conclusions.
|
||||
FORMATTING: Output only the summary text. Do not add any extra explanations, execution logs, or generation processes. You must use the following headings (if a section has no content, write "None"):
|
||||
Core Theme:
|
||||
Key Information:
|
||||
... (List 3-6 key points)
|
||||
Decisions/Conclusions:
|
||||
Action Items (with owner/deadline if any):
|
||||
Relevant Roles/Preferences:
|
||||
Risks/Dependencies/Assumptions:
|
||||
Points to Clarify:
|
||||
Compression Ratio: Original ~X words → Summary ~Y words (estimate)
|
||||
Conversation Content:
|
||||
{conversation_text}
|
||||
|
||||
Please directly output the compressed summary that meets the above requirements (summary text only).
|
||||
"""
|
||||
# Determine the model to use
|
||||
model = self.valves.summary_model or body.get("model", "")
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] Model: {model}")
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": summary_prompt}],
|
||||
"stream": False,
|
||||
"max_tokens": self.valves.max_summary_tokens,
|
||||
"temperature": self.valves.summary_temperature,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get user object
|
||||
user_id = user_data.get("id") if user_data else None
|
||||
if not user_id:
|
||||
raise ValueError("Could not get user ID")
|
||||
|
||||
# [Optimization] Get user object in a background thread to avoid blocking the event loop.
|
||||
if self.valves.debug_mode:
|
||||
print("[Optimization] Getting user object in a background thread to avoid blocking the event loop.")
|
||||
user = await asyncio.to_thread(Users.get_user_by_id, user_id)
|
||||
|
||||
if not user:
|
||||
raise ValueError(f"Could not find user: {user_id}")
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] User: {user.email}")
|
||||
print(f"[🤖 LLM Call] Sending request...")
|
||||
|
||||
# Create Request object
|
||||
request = Request(scope={"type": "http", "app": webui_app})
|
||||
|
||||
# Call generate_chat_completion
|
||||
response = await generate_chat_completion(request, payload, user)
|
||||
|
||||
if not response or "choices" not in response or not response["choices"]:
|
||||
raise ValueError("LLM response is not in the correct format or is empty")
|
||||
|
||||
summary = response["choices"][0]["message"]["content"].strip()
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] ✅ Successfully received summary.")
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"An error occurred while calling the LLM ({model}) to generate a summary: {str(e)}"
|
||||
if not self.valves.summary_model:
|
||||
error_message += (
|
||||
"\n[Hint] You did not specify a summary_model, so the filter attempted to use the current conversation's model. "
|
||||
"If this is a pipeline (Pipe) model or an incompatible model, please specify a compatible summary model (e.g., 'gemini-2.5-flash') in the configuration."
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] ❌ {error_message}")
|
||||
|
||||
raise Exception(error_message)
|
||||
@@ -0,0 +1,77 @@
|
||||
# 异步上下文压缩过滤器
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.0.0 | **许可证:** MIT
|
||||
|
||||
> **重要提示**:为了确保所有过滤器的可维护性和易用性,每个过滤器都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
|
||||
|
||||
本过滤器通过智能摘要和消息压缩技术,在保持对话连贯性的同时,显著降低长对话的Token消耗。
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
- ✅ **自动压缩**: 基于消息数量阈值自动触发上下文压缩。
|
||||
- ✅ **异步摘要**: 在后台生成摘要,不阻塞当前对话的响应。
|
||||
- ✅ **持久化存储**: 支持 PostgreSQL 和 SQLite 数据库,确保摘要在服务重启后不丢失。
|
||||
- ✅ **灵活保留策略**: 可自由配置保留对话头部和尾部的消息数量,确保关键信息和上下文的连贯性。
|
||||
- ✅ **智能注入**: 将生成的历史摘要智能地注入到新的上下文中。
|
||||
|
||||
---
|
||||
|
||||
## 安装与配置
|
||||
|
||||
### 1. 环境变量
|
||||
|
||||
本插件的运行依赖于数据库,您**必须**在 Open WebUI 的环境变量中配置 `DATABASE_URL`。
|
||||
|
||||
- **PostgreSQL 示例**:
|
||||
```
|
||||
DATABASE_URL=postgresql://user:password@host:5432/openwebui
|
||||
```
|
||||
- **SQLite 示例**:
|
||||
```
|
||||
DATABASE_URL=sqlite:///path/to/your/data/webui.db
|
||||
```
|
||||
|
||||
### 2. 过滤器顺序
|
||||
|
||||
建议将此过滤器的优先级设置得相对较高(数值较小),以确保它在其他可能修改消息内容的过滤器之前运行。一个典型的顺序可能是:
|
||||
|
||||
1. **前置过滤器 (priority < 10)**
|
||||
- 例如:注入系统级提示的过滤器。
|
||||
2. **本压缩过滤器 (priority = 10)**
|
||||
3. **后置过滤器 (priority > 10)**
|
||||
- 例如:对最终输出进行格式化的过滤器。
|
||||
|
||||
---
|
||||
|
||||
## 配置参数
|
||||
|
||||
您可以在过滤器的设置中调整以下参数:
|
||||
|
||||
| 参数 | 默认值 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| `priority` | `10` | 过滤器执行顺序,数值越小越先执行。 |
|
||||
| `compression_threshold` | `15` | 当总消息数达到此值时,将在后台触发摘要生成。 |
|
||||
| `keep_first` | `1` | 始终保留对话开始的 N 条消息。第一条消息通常包含重要的系统提示。 |
|
||||
| `keep_last` | `6` | 始终保留对话末尾的 N 条消息,以确保上下文连贯。 |
|
||||
| `summary_model` | `None` | 用于生成摘要的模型。**强烈建议**配置一个快速、经济的兼容模型(如 `gemini-2.5-flash`)。如果留空,将尝试使用当前对话的模型,但这可能因模型不兼容(如 Pipe 模型)而失败。 |
|
||||
| `max_summary_tokens` | `4000` | 生成摘要时允许的最大 Token 数。 |
|
||||
| `summary_temperature` | `0.3` | 控制摘要生成的随机性,较低的值结果更稳定。 |
|
||||
| `debug_mode` | `true` | 是否在日志中打印详细的调试信息。生产环境建议设为 `false`。 |
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **问题:数据库连接失败**
|
||||
- **解决**:请确认 `DATABASE_URL` 环境变量已正确设置,并且数据库服务运行正常。
|
||||
|
||||
- **问题:摘要未生成**
|
||||
- **解决**:检查 `compression_threshold` 是否已达到,并确认 `summary_model` 配置正确。查看日志以获取详细错误。
|
||||
|
||||
- **问题:初始的系统提示丢失**
|
||||
- **解决**:确保 `keep_first` 的值大于 0,以保留包含重要信息的初始消息。
|
||||
|
||||
- **问题:压缩效果不明显**
|
||||
- **解决**:尝试适当提高 `compression_threshold`,或减少 `keep_first` / `keep_last` 的值。
|
||||
662
plugins/filters/async-context-compression/工作流程指南.md
Normal file
662
plugins/filters/async-context-compression/工作流程指南.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# 异步上下文压缩过滤器 - 工作流程指南
|
||||
|
||||
## 📋 目录
|
||||
1. [概述](#概述)
|
||||
2. [系统架构](#系统架构)
|
||||
3. [工作流程详解](#工作流程详解)
|
||||
4. [Token 计数机制](#token-计数机制)
|
||||
5. [递归摘要机制](#递归摘要机制)
|
||||
6. [配置指南](#配置指南)
|
||||
7. [最佳实践](#最佳实践)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
异步上下文压缩过滤器是一个高性能的消息压缩插件,通过以下方式降低长对话的 Token 消耗:
|
||||
|
||||
- **智能摘要**:将历史消息压缩成高保真摘要
|
||||
- **递归更新**:新摘要合并旧摘要,保证历史连贯性
|
||||
- **异步处理**:后台生成摘要,不阻塞用户响应
|
||||
- **灵活配置**:支持全局和模型特定的阈值配置
|
||||
|
||||
### 核心指标
|
||||
- **压缩率**:可达 65% 以上(取决于对话长度)
|
||||
- **响应时间**:inlet 阶段 <10ms(无计算开销)
|
||||
- **摘要质量**:高保真递归摘要,保留关键信息
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 用户请求流程 │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ inlet(请求前处理) │
|
||||
│ ├─ 加载摘要记录 │
|
||||
│ ├─ 注入摘要到首条消息 │
|
||||
│ └─ 返回压缩消息列表 │ ◄─ 快速返回 (<10ms)
|
||||
└────────────┬──────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ LLM 处理消息 │
|
||||
│ ├─ 调用语言模型 │
|
||||
│ └─ 生成回复 │
|
||||
└────────────┬──────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ outlet(响应后处理) │
|
||||
│ ├─ 启动后台异步任务 │
|
||||
│ └─ 立即返回(不阻塞) │ ◄─ 返回响应给用户
|
||||
└────────────┬──────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ 后台处理(asyncio 任务) │
|
||||
│ ├─ 计算 Token 数 │
|
||||
│ ├─ 检查压缩阈值 │
|
||||
│ ├─ 生成递归摘要 │
|
||||
│ └─ 保存到数据库 │
|
||||
└────────────┬──────────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ 数据库持久化存储 │
|
||||
│ ├─ 摘要内容 │
|
||||
│ ├─ 压缩进度 │
|
||||
│ └─ 时间戳 │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程详解
|
||||
|
||||
### 1️⃣ inlet 阶段:消息注入与压缩视图构建
|
||||
|
||||
**目标**:快速应用已有摘要,构建压缩消息视图
|
||||
|
||||
**流程**:
|
||||
|
||||
```
|
||||
输入:所有消息列表
|
||||
│
|
||||
├─► 从数据库加载摘要记录
|
||||
│ │
|
||||
│ ├─► 找到 ✓ ─────┐
|
||||
│ └─► 未找到 ───┐ │
|
||||
│ │ │
|
||||
├──────────────────┴─┼─► 存在摘要?
|
||||
│ │
|
||||
│ ┌───▼───┐
|
||||
│ │ 是 │ 否
|
||||
│ └───┬───┴───┐
|
||||
│ │ │
|
||||
│ ┌───────────▼─┐ ┌─▼─────────┐
|
||||
│ │ 构建压缩视图 │ │ 使用原始 │
|
||||
│ │ [H] + [T] │ │ 消息列表 │
|
||||
│ └───────┬─────┘ └─┬────────┘
|
||||
│ │ │
|
||||
│ ┌───────────┴──────────┘
|
||||
│ │
|
||||
│ └─► 组合消息:
|
||||
│ • 头部(keep_first)
|
||||
│ • 摘要注入到首条
|
||||
│ • 尾部(keep_last)
|
||||
│
|
||||
└─────► 返回压缩消息列表
|
||||
⏱️ 耗时 <10ms
|
||||
```
|
||||
|
||||
**关键参数**:
|
||||
- `keep_first`:保留前 N 条消息(默认 1)
|
||||
- `keep_last`:保留后 N 条消息(默认 6)
|
||||
- 摘要注入位置:首条消息的内容前
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
# 原始:20 条消息
|
||||
消息1: [系统提示]
|
||||
消息2-14: [历史对话]
|
||||
消息15-20: [最近对话]
|
||||
|
||||
# inlet 后(存在摘要):7 条消息
|
||||
消息1: [系统提示 + 【历史摘要】...] ◄─ 摘要已注入
|
||||
消息15-20: [最近对话] ◄─ 保留后6条
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ outlet 阶段:后台异步处理
|
||||
|
||||
**目标**:计算 Token 数、检查阈值、生成摘要(不阻塞响应)
|
||||
|
||||
**流程**:
|
||||
|
||||
```
|
||||
LLM 响应完成
|
||||
│
|
||||
└─► outlet 处理
|
||||
│
|
||||
└─► 启动后台异步任务(asyncio.create_task)
|
||||
│
|
||||
├─► 立即返回给用户 ✓
|
||||
│ (不等待后台任务完成)
|
||||
│
|
||||
└─► 后台执行 _check_and_generate_summary_async
|
||||
│
|
||||
├─► 在后台线程中计算 Token 数
|
||||
│ (await asyncio.to_thread)
|
||||
│
|
||||
├─► 获取模型阈值配置
|
||||
│ • 优先使用 model_thresholds 中的配置
|
||||
│ • 回退到全局 compression_threshold_tokens
|
||||
│
|
||||
├─► 检查是否触发压缩
|
||||
│ if current_tokens >= threshold:
|
||||
│
|
||||
└─► 触发摘要生成流程
|
||||
```
|
||||
|
||||
**时序图**:
|
||||
```
|
||||
时间线:
|
||||
│
|
||||
├─ T0: LLM 响应完成
|
||||
│
|
||||
├─ T1: outlet 被调用
|
||||
│ └─► 启动后台任务
|
||||
│ └─► 立即返回 ✓
|
||||
│
|
||||
├─ T2: 用户收到响应 ✓✓✓
|
||||
│
|
||||
└─ T3-T10: 后台任务执行
|
||||
├─ 计算 Token
|
||||
├─ 检查阈值
|
||||
├─ 调用 LLM 生成摘要
|
||||
└─ 保存到数据库
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
- ✅ 用户响应不受影响
|
||||
- ✅ Token 计算不阻塞请求
|
||||
- ✅ 摘要生成异步进行
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Token 计数与阈值检查
|
||||
|
||||
**工作流程**:
|
||||
|
||||
```
|
||||
后台线程执行 _check_and_generate_summary_async
|
||||
│
|
||||
├─► Step 1: 计算当前 Token 总数
|
||||
│ │
|
||||
│ ├─ 遍历所有消息
|
||||
│ ├─ 处理多模态内容(提取文本部分)
|
||||
│ ├─ 使用 o200k_base 编码计数
|
||||
│ └─ 返回 total_tokens
|
||||
│
|
||||
├─► Step 2: 获取模型特定阈值
|
||||
│ │
|
||||
│ ├─ 模型 ID: gpt-4
|
||||
│ ├─ 查询 model_thresholds
|
||||
│ │
|
||||
│ ├─ 存在配置?
|
||||
│ │ ├─ 是 ✓ 使用该配置
|
||||
│ │ └─ 否 ✓ 使用全局参数
|
||||
│ │
|
||||
│ ├─ compression_threshold_tokens(默认 64000)
|
||||
│ └─ max_context_tokens(默认 128000)
|
||||
│
|
||||
└─► Step 3: 检查是否触发压缩
|
||||
│
|
||||
if current_tokens >= compression_threshold_tokens:
|
||||
│ └─► 触发摘要生成
|
||||
│
|
||||
else:
|
||||
└─► 无需压缩,任务结束
|
||||
```
|
||||
|
||||
**Token 计数细节**:
|
||||
|
||||
```python
|
||||
def _count_tokens(text):
|
||||
if tiktoken_available:
|
||||
# 使用 o200k_base(统一编码)
|
||||
encoding = tiktoken.get_encoding("o200k_base")
|
||||
return len(encoding.encode(text))
|
||||
else:
|
||||
# 回退:字符估算
|
||||
return len(text) // 4
|
||||
```
|
||||
|
||||
**模型阈值优先级**:
|
||||
```
|
||||
优先级 1: model_thresholds["gpt-4"]
|
||||
优先级 2: model_thresholds["gemini-2.5-flash"]
|
||||
优先级 3: 全局 compression_threshold_tokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 递归摘要生成
|
||||
|
||||
**核心机制**:将旧摘要与新消息合并,生成更新的摘要
|
||||
|
||||
**工作流程**:
|
||||
|
||||
```
|
||||
触发 _generate_summary_async
|
||||
│
|
||||
├─► Step 1: 加载旧摘要
|
||||
│ │
|
||||
│ ├─ 从数据库查询
|
||||
│ ├─ 获取 previous_summary
|
||||
│ └─ 获取 compressed_message_count(上次压缩进度)
|
||||
│
|
||||
├─► Step 2: 确定待压缩消息范围
|
||||
│ │
|
||||
│ ├─ start_index = max(compressed_count, keep_first)
|
||||
│ ├─ end_index = len(messages) - keep_last
|
||||
│ │
|
||||
│ ├─ 提取 messages[start_index:end_index]
|
||||
│ └─ 这是【新增对话】部分
|
||||
│
|
||||
├─► Step 3: 构建 LLM 提示词
|
||||
│ │
|
||||
│ ├─ 【已有摘要】= previous_summary
|
||||
│ ├─ 【新增对话】= 格式化的新消息
|
||||
│ │
|
||||
│ └─ 提示词模板:
|
||||
│ "将【已有摘要】和【新增对话】合并..."
|
||||
│
|
||||
├─► Step 4: 调用 LLM 生成摘要
|
||||
│ │
|
||||
│ ├─ 模型选择:summary_model(若配置)或当前模型
|
||||
│ ├─ 参数:
|
||||
│ │ • max_tokens = max_summary_tokens(默认 4000)
|
||||
│ │ • temperature = summary_temperature(默认 0.3)
|
||||
│ │ • stream = False
|
||||
│ │
|
||||
│ └─ 返回 new_summary
|
||||
│
|
||||
├─► Step 5: 保存摘要到数据库
|
||||
│ │
|
||||
│ ├─ 更新 chat_summary 表
|
||||
│ ├─ summary = new_summary
|
||||
│ ├─ compressed_message_count = end_index
|
||||
│ └─ updated_at = now()
|
||||
│
|
||||
└─► Step 6: 记录日志
|
||||
└─ 摘要长度、压缩进度、耗时等
|
||||
```
|
||||
|
||||
**递归摘要示例**:
|
||||
|
||||
```
|
||||
第一轮压缩:
|
||||
旧摘要: 无
|
||||
新消息: 消息2-14(13条)
|
||||
生成: Summary_V1
|
||||
|
||||
保存: compressed_message_count = 14
|
||||
|
||||
第二轮压缩:
|
||||
旧摘要: Summary_V1
|
||||
新消息: 消息15-28(从14开始)
|
||||
生成: Summary_V2 = LLM(Summary_V1 + 新消息14-28)
|
||||
|
||||
保存: compressed_message_count = 28
|
||||
|
||||
结果:
|
||||
✓ 早期信息得以保留(通过 Summary_V1)
|
||||
✓ 新信息与旧摘要融合
|
||||
✓ 历史连贯性维护
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token 计数机制
|
||||
|
||||
### 编码方案
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ _count_tokens(text) │
|
||||
├─────────────────────────────────┤
|
||||
│ 1. tiktoken 可用? │
|
||||
│ ├─ 是 ✓ │
|
||||
│ │ └─ use o200k_base │
|
||||
│ │ (最新模型适配) │
|
||||
│ │ │
|
||||
│ └─ 否 ✓ │
|
||||
│ └─ 字符估算 │
|
||||
│ (1 token ≈ 4 chars) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 多模态内容处理
|
||||
|
||||
```python
|
||||
# 消息结构
|
||||
message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "描述图片..."},
|
||||
{"type": "image_url", "image_url": {...}},
|
||||
{"type": "text", "text": "更多描述..."}
|
||||
]
|
||||
}
|
||||
|
||||
# Token 计数
|
||||
提取所有 text 部分 → 合并 → 计数
|
||||
图片部分被忽略(不消耗文本 token)
|
||||
```
|
||||
|
||||
### 计数流程
|
||||
|
||||
```
|
||||
_calculate_messages_tokens(messages, model)
|
||||
│
|
||||
├─► 遍历每条消息
|
||||
│ │
|
||||
│ ├─ content 是列表?
|
||||
│ │ ├─ 是 ✓ 提取所有文本部分
|
||||
│ │ └─ 否 ✓ 直接使用
|
||||
│ │
|
||||
│ └─ _count_tokens(content)
|
||||
│
|
||||
└─► 累加所有 Token 数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 递归摘要机制
|
||||
|
||||
### 保证历史连贯性的核心原理
|
||||
|
||||
```
|
||||
传统压缩方式(有问题):
|
||||
时间线:
|
||||
消息1-50 ─► 生成摘要1 ─► 保留 [摘要1 + 消息45-50]
|
||||
│
|
||||
消息51-100 ─► 生成摘要2 ─► 保留 [摘要2 + 消息95-100]
|
||||
└─► ❌ 摘要1 丢失!早期信息无法追溯
|
||||
|
||||
递归摘要方式(本实现):
|
||||
时间线:
|
||||
消息1-50 ──► 生成摘要1 ──► 保存
|
||||
│
|
||||
摘要1 + 消息51-100 ──► 生成摘要2 ──► 保存
|
||||
└─► ✓ 摘要1 信息融入摘要2
|
||||
✓ 历史信息连贯保存
|
||||
```
|
||||
|
||||
### 工作机制
|
||||
|
||||
```
|
||||
inlet 阶段:
|
||||
摘要库查询
|
||||
│
|
||||
├─ previous_summary(已有摘要)
|
||||
└─ compressed_message_count(压缩进度)
|
||||
|
||||
outlet 阶段:
|
||||
如果 current_tokens >= threshold:
|
||||
│
|
||||
├─ 新消息范围:
|
||||
│ [compressed_message_count : len(messages) - keep_last]
|
||||
│
|
||||
└─ LLM 处理:
|
||||
Input: previous_summary + 新消息
|
||||
Output: 更新的摘要(含早期信息 + 新信息)
|
||||
|
||||
保存进度:
|
||||
└─ compressed_message_count = end_index
|
||||
(下次压缩从这里开始)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置指南
|
||||
|
||||
### 全局配置
|
||||
|
||||
```python
|
||||
Valves(
|
||||
# Token 阈值
|
||||
compression_threshold_tokens=64000, # 触发压缩
|
||||
max_context_tokens=128000, # 硬性上限
|
||||
|
||||
# 消息保留策略
|
||||
keep_first=1, # 保留首条(系统提示)
|
||||
keep_last=6, # 保留末6条(最近对话)
|
||||
|
||||
# 摘要模型
|
||||
summary_model="gemini-2.5-flash", # 快速经济
|
||||
|
||||
# 摘要参数
|
||||
max_summary_tokens=4000,
|
||||
summary_temperature=0.3,
|
||||
)
|
||||
```
|
||||
|
||||
### 模型特定配置
|
||||
|
||||
```python
|
||||
model_thresholds = {
|
||||
"gpt-4": {
|
||||
"compression_threshold_tokens": 8000,
|
||||
"max_context_tokens": 32000
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"compression_threshold_tokens": 10000,
|
||||
"max_context_tokens": 40000
|
||||
},
|
||||
"llama-70b": {
|
||||
"compression_threshold_tokens": 20000,
|
||||
"max_context_tokens": 80000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置选择建议
|
||||
|
||||
```
|
||||
场景1:长对话成本优化
|
||||
compression_threshold_tokens: 32000 ◄─ 更早触发
|
||||
keep_last: 4 ◄─ 保留少一些
|
||||
|
||||
场景2:质量优先
|
||||
compression_threshold_tokens: 100000 ◄─ 晚触发
|
||||
keep_last: 10 ◄─ 保留多一些
|
||||
max_summary_tokens: 8000 ◄─ 更详细摘要
|
||||
|
||||
场景3:平衡方案(推荐)
|
||||
compression_threshold_tokens: 64000 ◄─ 默认
|
||||
keep_last: 6 ◄─ 默认
|
||||
summary_model: "gemini-2.5-flash" ◄─ 快速经济
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1️⃣ 摘要模型选择
|
||||
|
||||
```
|
||||
推荐模型:
|
||||
✅ gemini-2.5-flash 快速、经济、质量好
|
||||
✅ deepseek-v3 成本低、速度快
|
||||
✅ gpt-4o-mini 通用、质量稳定
|
||||
|
||||
避免:
|
||||
❌ 流水线(Pipe)模型 可能不支持标准 API
|
||||
❌ 本地模型 容易超时、影响体验
|
||||
```
|
||||
|
||||
### 2️⃣ 阈值调优
|
||||
|
||||
```
|
||||
Token 计数验证:
|
||||
1. 启用 debug_mode
|
||||
2. 观察实际 Token 数
|
||||
3. 根据需要调整阈值
|
||||
|
||||
# 日志示例
|
||||
[🔍 后台计算] Token 数: 45320
|
||||
[🔍 后台计算] 未触发压缩阈值 (Token: 45320 < 64000)
|
||||
```
|
||||
|
||||
### 3️⃣ 消息保留策略
|
||||
|
||||
```
|
||||
keep_first 配置:
|
||||
通常值: 1(保留系统提示)
|
||||
某些场景: 0(系统提示在摘要中)
|
||||
|
||||
keep_last 配置:
|
||||
通常值: 6(保留最近对话)
|
||||
长对话: 8-10(更多最近对话)
|
||||
短对话: 3-4(节省 Token)
|
||||
```
|
||||
|
||||
### 4️⃣ 监控与维护
|
||||
|
||||
```
|
||||
关键指标:
|
||||
• 摘要生成耗时
|
||||
• Token 节省率
|
||||
• 摘要质量(通过对话体验)
|
||||
|
||||
数据库维护:
|
||||
# 定期清理过期摘要
|
||||
DELETE FROM chat_summary
|
||||
WHERE updated_at < NOW() - INTERVAL '30 days'
|
||||
|
||||
# 统计压缩效果
|
||||
SELECT
|
||||
COUNT(*) as total_summaries,
|
||||
AVG(compressed_message_count) as avg_compressed
|
||||
FROM chat_summary
|
||||
```
|
||||
|
||||
### 5️⃣ 故障排除
|
||||
|
||||
```
|
||||
问题:摘要未生成
|
||||
检查项:
|
||||
1. Token 数是否达到阈值?
|
||||
→ debug_mode 查看日志
|
||||
2. summary_model 是否配置正确?
|
||||
→ 确保模型存在且可用
|
||||
3. 数据库连接是否正常?
|
||||
→ 检查 DATABASE_URL
|
||||
|
||||
问题:inlet 响应变慢
|
||||
检查项:
|
||||
1. keep_first/keep_last 是否过大?
|
||||
2. 摘要数据是否过大?
|
||||
3. 消息数是否过多?
|
||||
|
||||
问题:摘要质量下降
|
||||
调整方案:
|
||||
1. 增加 max_summary_tokens
|
||||
2. 降低 summary_temperature(更确定性)
|
||||
3. 更换摘要模型
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能参考
|
||||
|
||||
### 时间开销
|
||||
|
||||
```
|
||||
inlet 阶段:
|
||||
├─ 数据库查询: 1-2ms
|
||||
├─ 摘要注入: 2-3ms
|
||||
└─ 总计: <10ms ✓ (不影响用户体验)
|
||||
|
||||
outlet 阶段:
|
||||
├─ 启动后台任务: <1ms
|
||||
└─ 立即返回: ✓ (无等待)
|
||||
|
||||
后台处理(不阻塞用户):
|
||||
├─ Token 计数: 10-50ms
|
||||
├─ LLM 调用: 1-5 秒
|
||||
├─ 数据库保存: 1-2ms
|
||||
└─ 总计: 1-6 秒 (后台进行)
|
||||
```
|
||||
|
||||
### Token 节省示例
|
||||
|
||||
```
|
||||
场景:20 条消息对话
|
||||
|
||||
未压缩:
|
||||
总消息: 20 条
|
||||
预估 Token: 8000 个
|
||||
|
||||
压缩后(keep_first=1, keep_last=6):
|
||||
头部消息: 1 条 (1600 Token)
|
||||
摘要: ~800 Token (嵌入在头部)
|
||||
尾部消息: 6 条 (3200 Token)
|
||||
总计: 7 条有效输入 (~5600 Token)
|
||||
|
||||
节省:8000 - 5600 = 2400 Token (30% 节省)
|
||||
|
||||
随对话变长,节省比例可达 65% 以上
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据流图
|
||||
|
||||
```
|
||||
用户消息
|
||||
↓
|
||||
[inlet] 摘要注入器
|
||||
├─ 数据库 ← 查询摘要
|
||||
├─ 摘要注入到首条消息
|
||||
└─ 返回压缩消息列表
|
||||
↓
|
||||
LLM 处理
|
||||
├─ 调用语言模型
|
||||
├─ 生成响应
|
||||
└─ 返回给用户 ✓✓✓
|
||||
↓
|
||||
[outlet] 后台处理(asyncio 任务)
|
||||
├─ 计算 Token 数
|
||||
├─ 检查阈值
|
||||
├─ [if 需要] 调用 LLM 生成摘要
|
||||
│ ├─ 加载旧摘要
|
||||
│ ├─ 提取新消息
|
||||
│ ├─ 构建提示词
|
||||
│ └─ 调用 LLM
|
||||
├─ 保存新摘要到数据库
|
||||
└─ 记录日志
|
||||
↓
|
||||
数据库持久化
|
||||
└─ chat_summary 表更新
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 阶段 | 职责 | 耗时 | 特点 |
|
||||
|------|------|------|------|
|
||||
| **inlet** | 摘要注入 | <10ms | 快速、无计算 |
|
||||
| **LLM** | 生成回复 | 变量 | 正常流程 |
|
||||
| **outlet** | 启动后台 | <1ms | 不阻塞响应 |
|
||||
| **后台处理** | Token 计算、摘要生成、数据保存 | 1-6s | 异步执行 |
|
||||
|
||||
**核心优势**:
|
||||
- ✅ 用户响应不受影响
|
||||
- ✅ Token 消耗显著降低
|
||||
- ✅ 历史信息连贯保存
|
||||
- ✅ 灵活的配置选项
|
||||
1100
plugins/filters/async-context-compression/异步上下文压缩.py
Normal file
1100
plugins/filters/async-context-compression/异步上下文压缩.py
Normal file
File diff suppressed because it is too large
Load Diff
45
plugins/filters/async-context-compression/异步上下文压缩优化.md
Normal file
45
plugins/filters/async-context-compression/异步上下文压缩优化.md
Normal file
@@ -0,0 +1,45 @@
|
||||
需求文档:异步上下文压缩插件优化 (Async Context Compression Optimization)
|
||||
1. 核心目标 将现有的基于消息数量的压缩逻辑升级为基于 Token 数量的压缩逻辑,并引入递归摘要机制,以更精准地控制上下文窗口,提高摘要质量,并防止历史信息丢失。
|
||||
|
||||
2. 功能需求
|
||||
|
||||
Token 计数与阈值控制
|
||||
引入 tiktoken: 使用 tiktoken 库进行精确的 Token 计数。如果环境不支持,则回退到字符估算 (1 token ≈ 4 chars)。
|
||||
新配置参数 (Valves):
|
||||
compression_threshold_tokens (默认: 64000): 当上下文总 Token 数超过此值时,触发压缩(生成摘要)。
|
||||
max_context_tokens (默认: 128000): 上下文的硬性上限。如果超过此值,强制移除最早的消息(保留受保护消息除外)。
|
||||
model_thresholds (字典): 支持针对不同模型 ID 配置不同的阈值。例如:{'gpt-4': {'compression_threshold_tokens': 8000, ...}}。
|
||||
废弃旧参数: compression_threshold (基于消息数) 将被标记为废弃,优先使用 Token 阈值。
|
||||
递归摘要 (Recursive Summarization)
|
||||
机制: 在生成新摘要时,必须读取并包含上一次的摘要。
|
||||
逻辑: 新摘要 = LLM(上一次摘要 + 新产生的对话消息)。
|
||||
目的: 防止随着对话进行,最早期的摘要信息被丢弃,确保长期记忆的连续性。
|
||||
消息保护与修剪策略
|
||||
保护机制: keep_first (保留头部 N 条) 和 keep_last (保留尾部 N 条) 的消息绝对不参与压缩,也不被移除。
|
||||
修剪逻辑: 当触发 max_context_tokens 限制时,优先移除 keep_first 之后、keep_last 之前的最早消息。
|
||||
优化的提示词 (Prompt Engineering)
|
||||
目标: 去除无用信息(寒暄、重复),保留关键信号(事实、代码、决策)。
|
||||
指令:
|
||||
提炼与净化: 明确要求移除噪音。
|
||||
关键保留: 强调代码片段必须逐字保留。
|
||||
合并与更新: 明确指示将新信息合并到旧摘要中。
|
||||
语言一致性: 输出语言必须与对话语言保持一致。
|
||||
3. 实现细节
|
||||
|
||||
文件:
|
||||
async_context_compression.py
|
||||
类:
|
||||
Filter
|
||||
关键方法:
|
||||
_count_tokens(text): 实现 Token 计数。
|
||||
_calculate_messages_tokens(messages): 计算消息列表总 Token。
|
||||
_generate_summary_async(...)
|
||||
: 修改为加载旧摘要,并传入 LLM。
|
||||
_call_summary_llm(...)
|
||||
: 更新 Prompt,接受 previous_summary 和 new_messages。
|
||||
inlet(...)
|
||||
:
|
||||
使用 compression_threshold_tokens 判断是否注入摘要。
|
||||
实现 max_context_tokens 的强制修剪逻辑。
|
||||
outlet(...)
|
||||
: 使用 compression_threshold_tokens 判断是否触发后台摘要任务。
|
||||
@@ -0,0 +1,572 @@
|
||||
"""
|
||||
title: Context & Model Enhancement Filter
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.2
|
||||
|
||||
description:
|
||||
一个功能全面的 Filter 插件,用于增强请求上下文和优化模型功能。提供四大核心功能:
|
||||
|
||||
1. 环境变量注入:在每条用户消息前自动注入用户环境变量(用户名、时间、时区、语言等)
|
||||
- 支持纯文本、图片、多模态消息
|
||||
- 幂等性设计,避免重复注入
|
||||
- 注入成功时发送前端状态提示
|
||||
|
||||
2. Web Search 功能改进:为特定模型优化 Web 搜索功能
|
||||
- 为阿里云通义千问系列、DeepSeek、Gemini 等模型添加搜索能力
|
||||
- 自动识别模型并追加 "-search" 后缀
|
||||
- 管理功能开关,防止冲突
|
||||
- 启用时发送搜索能力状态提示
|
||||
|
||||
3. 模型适配与上下文注入:为特定模型注入 chat_id 等上下文信息
|
||||
- 支持 cfchatqwen、webgemini 等模型的特殊处理
|
||||
- 动态模型重定向
|
||||
- 智能化的模型识别和适配
|
||||
|
||||
4. 智能内容规范化:生产级的内容清洗与修复系统
|
||||
- 智能修复损坏的代码块(前缀、后缀、缩进)
|
||||
- 规范化 LaTeX 公式格式(行内/块级)
|
||||
- 优化思维链标签(</thought>)格式
|
||||
- 自动闭合未结束的代码块
|
||||
- 智能列表格式修复
|
||||
- 清理冗余的 XML 标签
|
||||
- 可配置的规则系统
|
||||
|
||||
features:
|
||||
- 自动化环境变量管理
|
||||
- 智能模型功能适配
|
||||
- 异步状态反馈
|
||||
- 幂等性保证
|
||||
- 多模型支持
|
||||
- 智能内容清洗与规范化
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Callable
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class NormalizerConfig:
|
||||
"""规范化配置类,用于动态启用/禁用特定规则"""
|
||||
enable_escape_fix: bool = True # 修复转义字符
|
||||
enable_thought_tag_fix: bool = True # 修复思考链标签
|
||||
enable_code_block_fix: bool = True # 修复代码块格式
|
||||
enable_latex_fix: bool = True # 修复 LaTeX 公式格式
|
||||
enable_list_fix: bool = False # 修复列表换行
|
||||
enable_unclosed_block_fix: bool = True # 修复未闭合代码块
|
||||
enable_fullwidth_symbol_fix: bool = False # 修复代码内的全角符号
|
||||
enable_xml_tag_cleanup: bool = True # 清理 XML 残留标签
|
||||
|
||||
# 自定义清理函数列表(高级扩展用)
|
||||
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
|
||||
|
||||
class ContentNormalizer:
|
||||
"""LLM 输出内容规范化器 - 生产级实现"""
|
||||
|
||||
# --- 1. 预编译正则表达式(性能优化) ---
|
||||
_PATTERNS = {
|
||||
# 代码块前缀:如果 ``` 前面不是行首也不是换行符
|
||||
'code_block_prefix': re.compile(r'(?<!^)(?<!\n)(```)', re.MULTILINE),
|
||||
|
||||
# 代码块后缀:匹配 ```语言名 后面紧跟非空白字符(没有换行)
|
||||
# 匹配 ```python code 这种情况,但不匹配 ```python 或 ```python\n
|
||||
'code_block_suffix': re.compile(r'(```[\w\+\-\.]*)[ \t]+([^\n\r])'),
|
||||
|
||||
# 代码块缩进:行首的空白字符 + ```
|
||||
'code_block_indent': re.compile(r'^[ \t]+(```)', re.MULTILINE),
|
||||
|
||||
# 思考链标签:</thought> 后可能跟空格或换行
|
||||
'thought_tag': re.compile(r'</thought>[ \t]*\n*'),
|
||||
|
||||
# LaTeX 块级公式:\[ ... \]
|
||||
'latex_bracket_block': re.compile(r'\\\[(.+?)\\\]', re.DOTALL),
|
||||
# LaTeX 行内公式:\( ... \)
|
||||
'latex_paren_inline': re.compile(r'\\\((.+?)\\\)'),
|
||||
|
||||
# 列表项:非换行符 + 数字 + 点 + 空格 (e.g. "Text1. Item")
|
||||
'list_item': re.compile(r'([^\n])(\d+\. )'),
|
||||
|
||||
# XML 残留标签 (如 Claude 的 artifacts)
|
||||
'xml_artifacts': re.compile(r'</?(?:antArtifact|antThinking|artifact)[^>]*>', re.IGNORECASE),
|
||||
}
|
||||
|
||||
def __init__(self, config: Optional[NormalizerConfig] = None):
|
||||
self.config = config or NormalizerConfig()
|
||||
self.applied_fixes = []
|
||||
|
||||
def normalize(self, content: str) -> str:
|
||||
"""主入口:按顺序应用所有规范化规则"""
|
||||
self.applied_fixes = []
|
||||
if not content:
|
||||
return content
|
||||
|
||||
try:
|
||||
# 1. 转义字符修复(必须最先执行,否则影响后续正则)
|
||||
if self.config.enable_escape_fix:
|
||||
original = content
|
||||
content = self._fix_escape_characters(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("修复转义字符")
|
||||
|
||||
# 2. 思考链标签规范化
|
||||
if self.config.enable_thought_tag_fix:
|
||||
original = content
|
||||
content = self._fix_thought_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("规范化思考链")
|
||||
|
||||
# 3. 代码块格式修复
|
||||
if self.config.enable_code_block_fix:
|
||||
original = content
|
||||
content = self._fix_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("修复代码块格式")
|
||||
|
||||
# 4. LaTeX 公式规范化
|
||||
if self.config.enable_latex_fix:
|
||||
original = content
|
||||
content = self._fix_latex_formulas(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("规范化 LaTeX 公式")
|
||||
|
||||
# 5. 列表格式修复
|
||||
if self.config.enable_list_fix:
|
||||
original = content
|
||||
content = self._fix_list_formatting(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("修复列表格式")
|
||||
|
||||
# 6. 未闭合代码块检测与修复
|
||||
if self.config.enable_unclosed_block_fix:
|
||||
original = content
|
||||
content = self._fix_unclosed_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("闭合未结束代码块")
|
||||
|
||||
# 7. 全角符号转半角(仅代码块内)
|
||||
if self.config.enable_fullwidth_symbol_fix:
|
||||
original = content
|
||||
content = self._fix_fullwidth_symbols_in_code(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("全角符号转半角")
|
||||
|
||||
# 8. XML 标签残留清理
|
||||
if self.config.enable_xml_tag_cleanup:
|
||||
original = content
|
||||
content = self._cleanup_xml_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("清理 XML 标签")
|
||||
|
||||
# 9. 执行自定义清理函数
|
||||
for cleaner in self.config.custom_cleaners:
|
||||
original = content
|
||||
content = cleaner(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("执行自定义清理")
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
# 生产环境保底机制:如果清洗过程报错,返回原始内容,避免阻断服务
|
||||
logger.error(f"内容规范化失败: {e}", exc_info=True)
|
||||
return content
|
||||
|
||||
def _fix_escape_characters(self, content: str) -> str:
|
||||
"""修复过度转义的字符"""
|
||||
# 注意:先处理具体的转义序列,再处理通用的双反斜杠
|
||||
content = content.replace("\\r\\n", "\n")
|
||||
content = content.replace("\\n", "\n")
|
||||
content = content.replace("\\t", "\t")
|
||||
# 修复过度转义的反斜杠 (例如路径 C:\\Users)
|
||||
content = content.replace("\\\\", "\\")
|
||||
return content
|
||||
|
||||
def _fix_thought_tags(self, content: str) -> str:
|
||||
"""规范化 </thought> 标签,统一为空两行"""
|
||||
return self._PATTERNS['thought_tag'].sub("</thought>\n\n", content)
|
||||
|
||||
def _fix_code_blocks(self, content: str) -> str:
|
||||
"""修复代码块格式(独占行、换行、去缩进)"""
|
||||
# C: 移除代码块前的缩进(必须先执行,否则影响下面的判断)
|
||||
content = self._PATTERNS['code_block_indent'].sub(r"\1", content)
|
||||
# A: 确保 ``` 前有换行
|
||||
content = self._PATTERNS['code_block_prefix'].sub(r"\n\1", content)
|
||||
# B: 确保 ```语言标识 后有换行
|
||||
content = self._PATTERNS['code_block_suffix'].sub(r"\1\n\2", content)
|
||||
return content
|
||||
|
||||
def _fix_latex_formulas(self, content: str) -> str:
|
||||
"""规范化 LaTeX 公式:\[ -> $$ (块级), \( -> $ (行内)"""
|
||||
content = self._PATTERNS['latex_bracket_block'].sub(r"$$\1$$", content)
|
||||
content = self._PATTERNS['latex_paren_inline'].sub(r"$\1$", content)
|
||||
return content
|
||||
|
||||
def _fix_list_formatting(self, content: str) -> str:
|
||||
"""修复列表项缺少换行的问题 (如 'text1. item' -> 'text\\n1. item')"""
|
||||
return self._PATTERNS['list_item'].sub(r"\1\n\2", content)
|
||||
|
||||
def _fix_unclosed_code_blocks(self, content: str) -> str:
|
||||
"""检测并修复未闭合的代码块"""
|
||||
if content.count("```") % 2 != 0:
|
||||
logger.warning("检测到未闭合的代码块,自动补全")
|
||||
content += "\n```"
|
||||
return content
|
||||
|
||||
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
|
||||
"""在代码块内将全角符号转为半角(精细化操作)"""
|
||||
# 常见误用的全角符号映射
|
||||
FULLWIDTH_MAP = {
|
||||
',': ',', '。': '.', '(': '(', ')': ')',
|
||||
'【': '[', '】': ']', ';': ';', ':': ':',
|
||||
'?': '?', '!': '!', '"': '"', '"': '"',
|
||||
''': "'", ''': "'",
|
||||
}
|
||||
|
||||
parts = content.split("```")
|
||||
# 代码块内容位于索引 1, 3, 5... (奇数位)
|
||||
for i in range(1, len(parts), 2):
|
||||
for full, half in FULLWIDTH_MAP.items():
|
||||
parts[i] = parts[i].replace(full, half)
|
||||
|
||||
return "```".join(parts)
|
||||
|
||||
def _cleanup_xml_tags(self, content: str) -> str:
|
||||
"""移除无关的 XML 标签"""
|
||||
return self._PATTERNS['xml_artifacts'].sub("", content)
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
priority: int = Field(
|
||||
default=0, description="Priority level for the filter operations."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
# Indicates custom file handling logic. This flag helps disengage default routines in favor of custom
|
||||
# implementations, informing the WebUI to defer file-related operations to designated methods within this class.
|
||||
# Alternatively, you can remove the files directly from the body in from the inlet hook
|
||||
# self.file_handler = True
|
||||
|
||||
# Initialize 'valves' with specific configurations. Using 'Valves' instance helps encapsulate settings,
|
||||
# which ensures settings are managed cohesively and not confused with operational flags like 'file_handler'.
|
||||
self.valves = self.Valves()
|
||||
pass
|
||||
|
||||
def inlet(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[dict] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__model__: Optional[dict] = None,
|
||||
__event_emitter__=None,
|
||||
) -> dict:
|
||||
# Modify the request body or validate it before processing by the chat completion API.
|
||||
# This function is the pre-processor for the API where various checks on the input can be performed.
|
||||
# It can also modify the request before sending it to the API.
|
||||
messages = body.get("messages", [])
|
||||
self.insert_user_env_info(__metadata__, messages, __event_emitter__)
|
||||
# if "测试系统提示词" in str(messages):
|
||||
# messages.insert(0, {"role": "system", "content": "你是一个大数学家"})
|
||||
# print("XXXXX" * 100)
|
||||
# print(body)
|
||||
self.change_web_search(body, __user__, __event_emitter__)
|
||||
body = self.inlet_chat_id(__model__, __metadata__, body)
|
||||
|
||||
return body
|
||||
|
||||
def inlet_chat_id(self, model: dict, metadata: dict, body: dict):
|
||||
if "openai" in model:
|
||||
base_model_id = model["openai"]["id"]
|
||||
|
||||
else:
|
||||
base_model_id = model["info"]["base_model_id"]
|
||||
|
||||
base_model = model["id"] if base_model_id is None else base_model_id
|
||||
if base_model.startswith("cfchatqwen"):
|
||||
# pass
|
||||
body["chat_id"] = metadata["chat_id"]
|
||||
|
||||
if base_model.startswith("webgemini"):
|
||||
body["chat_id"] = metadata["chat_id"]
|
||||
if not model["id"].startswith("webgemini"):
|
||||
body["custom_model_id"] = model["id"]
|
||||
|
||||
# print("我是 body *******************", body)
|
||||
return body
|
||||
|
||||
def change_web_search(self, body, __user__, __event_emitter__=None):
|
||||
"""
|
||||
优化特定模型的 Web 搜索功能。
|
||||
|
||||
功能:
|
||||
- 检测是否启用了 Web 搜索
|
||||
- 为支持搜索的模型启用模型本身的搜索能力
|
||||
- 禁用默认的 web_search 开关以避免冲突
|
||||
- 当使用模型本身的搜索能力时发送状态提示
|
||||
|
||||
参数:
|
||||
body: 请求体字典
|
||||
__user__: 用户信息
|
||||
__event_emitter__: 用于发送前端事件的发射器函数
|
||||
"""
|
||||
features = body.get("features", {})
|
||||
web_search_enabled = (
|
||||
features.get("web_search", False) if isinstance(features, dict) else False
|
||||
)
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_email = __user__[0].get("email", "用户") if __user__[0] else "用户"
|
||||
elif isinstance(__user__, dict):
|
||||
user_email = __user__.get("email", "用户")
|
||||
model_name = body.get("model")
|
||||
|
||||
search_enabled_for_model = False
|
||||
if web_search_enabled:
|
||||
if model_name in ["qwen-max-latest", "qwen-max", "qwen-plus-latest"]:
|
||||
body.setdefault("enable_search", True)
|
||||
features["web_search"] = False
|
||||
search_enabled_for_model = True
|
||||
if "search" in model_name or "搜索" in model_name:
|
||||
features["web_search"] = False
|
||||
if model_name.startswith("cfdeepseek-deepseek") and not model_name.endswith(
|
||||
"search"
|
||||
):
|
||||
body["model"] = body["model"] + "-search"
|
||||
features["web_search"] = False
|
||||
search_enabled_for_model = True
|
||||
if model_name.startswith("cfchatqwen") and not model_name.endswith(
|
||||
"search"
|
||||
):
|
||||
body["model"] = body["model"] + "-search"
|
||||
features["web_search"] = False
|
||||
search_enabled_for_model = True
|
||||
if model_name.startswith("gemini-2.5") and "search" not in model_name:
|
||||
body["model"] = body["model"] + "-search"
|
||||
features["web_search"] = False
|
||||
search_enabled_for_model = True
|
||||
if user_email == "yi204o@qq.com":
|
||||
features["web_search"] = False
|
||||
|
||||
# 如果启用了模型本身的搜索能力,发送状态提示
|
||||
if search_enabled_for_model and __event_emitter__:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.create_task(
|
||||
self._emit_search_status(__event_emitter__, model_name)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def insert_user_env_info(
|
||||
self, __metadata__, messages, __event_emitter__=None, model_match_tags=None
|
||||
):
|
||||
"""
|
||||
在第一条用户消息中注入环境变量信息。
|
||||
|
||||
功能特性:
|
||||
- 始终在用户消息内容前注入环境变量的 Markdown 说明
|
||||
- 支持多种消息类型:纯文本、图片、图文混合消息
|
||||
- 幂等性设计:若环境变量信息已存在则更新为最新数据,不会重复添加
|
||||
- 注入成功后通过事件发射器向前端发送"注入成功"的状态提示
|
||||
|
||||
参数:
|
||||
__metadata__: 包含环境变量的元数据字典
|
||||
messages: 消息列表
|
||||
__event_emitter__: 用于发送前端事件的发射器函数
|
||||
model_match_tags: 模型匹配标签(保留参数,当前未使用)
|
||||
"""
|
||||
variables = __metadata__.get("variables", {})
|
||||
if not messages or messages[0]["role"] != "user":
|
||||
return
|
||||
|
||||
env_injected = False
|
||||
if variables:
|
||||
# 构建环境变量的Markdown文本
|
||||
variable_markdown = (
|
||||
"## 用户环境变量\n"
|
||||
"以下信息为用户的环境变量,可用于为用户提供更个性化的服务或满足特定需求时作为参考:\n"
|
||||
f"- **用户姓名**:{variables.get('{{USER_NAME}}', '')}\n"
|
||||
f"- **当前日期时间**:{variables.get('{{CURRENT_DATETIME}}', '')}\n"
|
||||
f"- **当前星期**:{variables.get('{{CURRENT_WEEKDAY}}', '')}\n"
|
||||
f"- **当前时区**:{variables.get('{{CURRENT_TIMEZONE}}', '')}\n"
|
||||
f"- **用户语言**:{variables.get('{{USER_LANGUAGE}}', '')}\n"
|
||||
)
|
||||
|
||||
content = messages[0]["content"]
|
||||
# 环境变量部分的匹配模式
|
||||
env_var_pattern = r"(## 用户环境变量\n以下信息为用户的环境变量,可用于为用户提供更个性化的服务或满足特定需求时作为参考:\n.*?用户语言.*?\n)"
|
||||
# 处理不同内容类型
|
||||
if isinstance(content, list): # 多模态内容(可能包含图片和文本)
|
||||
# 查找第一个文本类型的内容
|
||||
text_index = -1
|
||||
for i, part in enumerate(content):
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_index = i
|
||||
break
|
||||
|
||||
if text_index >= 0:
|
||||
# 存在文本内容,检查是否已存在环境变量信息
|
||||
text_part = content[text_index]
|
||||
text_content = text_part.get("text", "")
|
||||
|
||||
if re.search(env_var_pattern, text_content, flags=re.DOTALL):
|
||||
# 已存在环境变量信息,更新为最新数据
|
||||
text_part["text"] = re.sub(
|
||||
env_var_pattern,
|
||||
variable_markdown,
|
||||
text_content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
else:
|
||||
# 不存在环境变量信息,添加到开头
|
||||
text_part["text"] = f"{variable_markdown}\n{text_content}"
|
||||
|
||||
content[text_index] = text_part
|
||||
else:
|
||||
# 没有文本内容(例如只有图片),添加新的文本项
|
||||
content.insert(
|
||||
0, {"type": "text", "text": f"{variable_markdown}\n"}
|
||||
)
|
||||
|
||||
messages[0]["content"] = content
|
||||
|
||||
elif isinstance(content, str): # 纯文本内容
|
||||
# 检查是否已存在环境变量信息
|
||||
if re.search(env_var_pattern, content, flags=re.DOTALL):
|
||||
# 已存在,更新为最新数据
|
||||
messages[0]["content"] = re.sub(
|
||||
env_var_pattern, variable_markdown, content, flags=re.DOTALL
|
||||
)
|
||||
else:
|
||||
# 不存在,添加到开头
|
||||
messages[0]["content"] = f"{variable_markdown}\n{content}"
|
||||
env_injected = True
|
||||
|
||||
else: # 其他类型内容
|
||||
# 转换为字符串并处理
|
||||
str_content = str(content)
|
||||
# 检查是否已存在环境变量信息
|
||||
if re.search(env_var_pattern, str_content, flags=re.DOTALL):
|
||||
# 已存在,更新为最新数据
|
||||
messages[0]["content"] = re.sub(
|
||||
env_var_pattern, variable_markdown, str_content, flags=re.DOTALL
|
||||
)
|
||||
else:
|
||||
# 不存在,添加到开头
|
||||
messages[0]["content"] = f"{variable_markdown}\n{str_content}"
|
||||
env_injected = True
|
||||
|
||||
# 环境变量注入成功后,发送状态提示给用户
|
||||
if env_injected and __event_emitter__:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
# 如果在异步环境中,使用 await
|
||||
asyncio.create_task(self._emit_env_status(__event_emitter__))
|
||||
except RuntimeError:
|
||||
# 如果不在异步环境中,直接调用
|
||||
pass
|
||||
|
||||
async def _emit_env_status(self, __event_emitter__):
|
||||
"""
|
||||
发送环境变量注入成功的状态提示给前端用户
|
||||
"""
|
||||
try:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "✓ 用户环境变量已注入成功",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"发送状态提示时出错: {e}")
|
||||
|
||||
async def _emit_search_status(self, __event_emitter__, model_name):
|
||||
"""
|
||||
发送模型搜索功能启用的状态提示给前端用户
|
||||
"""
|
||||
try:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"🔍 已为 {model_name} 启用搜索能力",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"发送搜索状态提示时出错: {e}")
|
||||
|
||||
async def _emit_normalization_status(self, __event_emitter__, applied_fixes: List[str] = None):
|
||||
"""
|
||||
发送内容规范化完成的状态提示
|
||||
"""
|
||||
description = "✓ 内容已自动规范化"
|
||||
if applied_fixes:
|
||||
description += f":{', '.join(applied_fixes)}"
|
||||
|
||||
try:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": description,
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"发送规范化状态提示时出错: {e}")
|
||||
|
||||
def _contains_html(self, content: str) -> bool:
|
||||
"""
|
||||
检测内容是否包含 HTML 标签
|
||||
"""
|
||||
# 匹配常见的 HTML 标签
|
||||
pattern = r"<\s*/?\s*(?:html|head|body|div|span|p|br|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|b|i|strong|em|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
|
||||
return bool(re.search(pattern, content, re.IGNORECASE))
|
||||
|
||||
def outlet(self, body: dict, __user__: Optional[dict] = None, __event_emitter__=None) -> dict:
|
||||
"""
|
||||
处理传出响应体,通过修改最后一条助手消息的内容。
|
||||
使用 ContentNormalizer 进行全面的内容规范化。
|
||||
"""
|
||||
if "messages" in body and body["messages"]:
|
||||
last = body["messages"][-1]
|
||||
content = last.get("content", "") or ""
|
||||
|
||||
if last.get("role") == "assistant" and isinstance(content, str):
|
||||
# 如果包含 HTML,跳过规范化,为了防止错误格式化
|
||||
if self._contains_html(content):
|
||||
return body
|
||||
|
||||
# 初始化规范化器
|
||||
normalizer = ContentNormalizer()
|
||||
|
||||
# 执行规范化
|
||||
new_content = normalizer.normalize(content)
|
||||
|
||||
# 更新内容
|
||||
if new_content != content:
|
||||
last["content"] = new_content
|
||||
# 如果内容发生了改变,发送状态提示
|
||||
if __event_emitter__:
|
||||
import asyncio
|
||||
try:
|
||||
# 传入 applied_fixes
|
||||
asyncio.create_task(self._emit_normalization_status(__event_emitter__, normalizer.applied_fixes))
|
||||
except RuntimeError:
|
||||
# 假如不在循环中,则忽略
|
||||
pass
|
||||
|
||||
return body
|
||||
File diff suppressed because it is too large
Load Diff
212
plugins/filters/multi_model_context_merger.py
Normal file
212
plugins/filters/multi_model_context_merger.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import asyncio
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
# 注入的系统消息的前缀
|
||||
CONTEXT_PREFIX: str = Field(
|
||||
default="下面是多个匿名AI模型给出的回答,使用<response>标签包裹:\n\n",
|
||||
description="Prefix for the injected system message containing the raw merged context.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.toggle = True
|
||||
self.type = "filter"
|
||||
self.name = "合并回答"
|
||||
self.description = "在用户提问时,自动注入之前多个模型回答的上下文。"
|
||||
|
||||
async def inlet(
|
||||
self,
|
||||
body: Dict,
|
||||
__user__: Dict,
|
||||
__metadata__: Dict,
|
||||
__request__: Request,
|
||||
__event_emitter__,
|
||||
):
|
||||
"""
|
||||
此方法是过滤器的入口点。它会检查上一回合是否为多模型响应,
|
||||
如果是,则将这些响应直接格式化,并将格式化后的上下文作为系统消息注入到当前请求中。
|
||||
"""
|
||||
print(f"*********** Filter '{self.name}' triggered ***********")
|
||||
chat_id = __metadata__.get("chat_id")
|
||||
if not chat_id:
|
||||
print(
|
||||
f"DEBUG: Filter '{self.name}' skipped: chat_id not found in metadata."
|
||||
)
|
||||
return body
|
||||
|
||||
print(f"DEBUG: Chat ID found: {chat_id}")
|
||||
|
||||
# 1. 从数据库获取完整的聊天历史
|
||||
try:
|
||||
chat = await asyncio.to_thread(Chats.get_chat_by_id, chat_id)
|
||||
|
||||
if (
|
||||
not chat
|
||||
or not hasattr(chat, "chat")
|
||||
or not chat.chat.get("history")
|
||||
or not chat.chat.get("history").get("messages")
|
||||
):
|
||||
print(
|
||||
f"DEBUG: Filter '{self.name}' skipped: Chat history not found or empty for chat_id: {chat_id}"
|
||||
)
|
||||
return body
|
||||
|
||||
messages_map = chat.chat["history"]["messages"]
|
||||
print(
|
||||
f"DEBUG: Successfully loaded {len(messages_map)} messages from history."
|
||||
)
|
||||
|
||||
# Count the number of user messages in the history
|
||||
user_message_count = sum(
|
||||
1 for msg in messages_map.values() if msg.get("role") == "user"
|
||||
)
|
||||
|
||||
# If there are less than 2 user messages, there's no previous turn to merge.
|
||||
if user_message_count < 2:
|
||||
print(
|
||||
f"DEBUG: Filter '{self.name}' skipped: Not enough user messages in history to have a previous turn (found {user_message_count}, required >= 2)."
|
||||
)
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
f"ERROR: Filter '{self.name}' failed to get chat history from DB: {e}"
|
||||
)
|
||||
return body
|
||||
|
||||
# This filter rebuilds the entire chat history to consolidate all multi-response turns.
|
||||
|
||||
# 1. Get all messages from history and sort by timestamp
|
||||
all_messages = list(messages_map.values())
|
||||
all_messages.sort(key=lambda x: x.get("timestamp", 0))
|
||||
|
||||
# 2. Pre-group all assistant messages by their parentId for efficient lookup
|
||||
assistant_groups = {}
|
||||
for msg in all_messages:
|
||||
if msg.get("role") == "assistant":
|
||||
parent_id = msg.get("parentId")
|
||||
if parent_id:
|
||||
if parent_id not in assistant_groups:
|
||||
assistant_groups[parent_id] = []
|
||||
assistant_groups[parent_id].append(msg)
|
||||
|
||||
final_messages = []
|
||||
processed_parent_ids = set()
|
||||
|
||||
# 3. Iterate through the sorted historical messages to build the final, clean list
|
||||
for msg in all_messages:
|
||||
msg_id = msg.get("id")
|
||||
role = msg.get("role")
|
||||
parent_id = msg.get("parentId")
|
||||
|
||||
if role == "user":
|
||||
# Add user messages directly
|
||||
final_messages.append(msg)
|
||||
|
||||
elif role == "assistant":
|
||||
# If this assistant's parent group has already been processed, skip it
|
||||
if parent_id in processed_parent_ids:
|
||||
continue
|
||||
|
||||
# Process the group of siblings for this parent_id
|
||||
if parent_id in assistant_groups:
|
||||
siblings = assistant_groups[parent_id]
|
||||
|
||||
# Only perform a merge if there are multiple siblings
|
||||
if len(siblings) > 1:
|
||||
print(
|
||||
f"DEBUG: Found a group of {len(siblings)} siblings for parent_id {parent_id}. Merging..."
|
||||
)
|
||||
|
||||
# --- MERGE LOGIC ---
|
||||
merged_content = None
|
||||
merged_message_id = None
|
||||
# Sort siblings by timestamp before processing
|
||||
siblings.sort(key=lambda s: s.get("timestamp", 0))
|
||||
merged_message_timestamp = siblings[0].get("timestamp", 0)
|
||||
|
||||
# Case A: Check for system pre-merged content (merged.status: true and content not empty)
|
||||
merged_content_msg = next(
|
||||
(
|
||||
s
|
||||
for s in siblings
|
||||
if s.get("merged", {}).get("status")
|
||||
and s.get("merged", {}).get("content")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if merged_content_msg:
|
||||
merged_content = merged_content_msg["merged"]["content"]
|
||||
merged_message_id = merged_content_msg["id"]
|
||||
merged_message_timestamp = merged_content_msg.get(
|
||||
"timestamp", merged_message_timestamp
|
||||
)
|
||||
print(
|
||||
f"DEBUG: Using pre-merged content from message ID: {merged_message_id}"
|
||||
)
|
||||
else:
|
||||
# Case B: Manually merge content
|
||||
combined_content = []
|
||||
first_sibling_id = None
|
||||
counter = 0
|
||||
|
||||
for s in siblings:
|
||||
if not first_sibling_id:
|
||||
first_sibling_id = s["id"]
|
||||
|
||||
content = s.get("content", "")
|
||||
if (
|
||||
content
|
||||
and content
|
||||
!= "The requested model is not supported."
|
||||
):
|
||||
response_id = chr(ord("a") + counter)
|
||||
combined_content.append(
|
||||
f'<response id="{response_id}">\n{content}\n</response>'
|
||||
)
|
||||
counter += 1
|
||||
|
||||
if combined_content:
|
||||
merged_content = "\n\n".join(combined_content)
|
||||
merged_message_id = first_sibling_id or parent_id
|
||||
|
||||
if merged_content:
|
||||
merged_message = {
|
||||
"id": merged_message_id,
|
||||
"parentId": parent_id,
|
||||
"role": "assistant",
|
||||
"content": f"{self.valves.CONTEXT_PREFIX}{merged_content}",
|
||||
"timestamp": merged_message_timestamp,
|
||||
}
|
||||
final_messages.append(merged_message)
|
||||
else:
|
||||
# If there's only one sibling, add it directly
|
||||
final_messages.append(siblings[0])
|
||||
|
||||
# Mark this group as processed
|
||||
processed_parent_ids.add(parent_id)
|
||||
|
||||
# 4. The new user message from the current request is not in the historical messages_map,
|
||||
# so we need to append it to our newly constructed message list.
|
||||
if body.get("messages"):
|
||||
new_user_message_from_body = body["messages"][-1]
|
||||
# Ensure we don't add a historical message that might be in the body for context
|
||||
if new_user_message_from_body.get("id") not in messages_map:
|
||||
final_messages.append(new_user_message_from_body)
|
||||
|
||||
# 5. Replace the original message list with the new, cleaned-up list
|
||||
body["messages"] = final_messages
|
||||
print(
|
||||
f"DEBUG: Rebuilt message history with {len(final_messages)} messages, consolidating all multi-response turns."
|
||||
)
|
||||
|
||||
print(f"*********** Filter '{self.name}' finished successfully ***********")
|
||||
return body
|
||||
208
plugins/pipelines/moe_prompt_refiner.py
Normal file
208
plugins/pipelines/moe_prompt_refiner.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""
|
||||
该管道用于优化多模型(MoE)汇总请求的提示词。
|
||||
|
||||
它会拦截用于汇总多个模型响应的请求,提取原始用户查询和各个模型的具体回答,
|
||||
然后构建一个新的、更详细、结构化的提示词。
|
||||
|
||||
这个经过优化的提示词会引导最终的汇总模型扮演一个专家分析师的角色,
|
||||
将输入信息整合成一份高质量、全面的综合报告。
|
||||
"""
|
||||
|
||||
class Valves(BaseModel):
|
||||
# 指定该过滤器管道将连接到的目标管道ID(模型)。
|
||||
# 如果希望连接到所有管道,可以设置为 ["*"]。
|
||||
pipelines: List[str] = ["*"]
|
||||
|
||||
# 为过滤器管道分配一个优先级。
|
||||
# 优先级决定了过滤器管道的执行顺序。
|
||||
# 数字越小,优先级越高。
|
||||
priority: int = 0
|
||||
|
||||
def __init__(self):
|
||||
self.type = "filter"
|
||||
self.name = "moe_prompt_refiner"
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def on_startup(self):
|
||||
# 此函数在服务器启动时调用。
|
||||
# print(f"on_startup:{__name__}")
|
||||
pass
|
||||
|
||||
async def on_shutdown(self):
|
||||
# 此函数在服务器停止时调用。
|
||||
# print(f"on_shutdown:{__name__}")
|
||||
pass
|
||||
|
||||
async def inlet(self, body: dict, user: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
此方法是管道的入口点。
|
||||
|
||||
它会检查传入的请求是否为多模型(MoE)汇总请求。如果是,它会解析原始提示词,
|
||||
提取用户的查询和来自不同模型的响应。然后,它会动态构建一个新的、结构更清晰的提示词,
|
||||
并用它替换原始的消息内容。
|
||||
|
||||
参数:
|
||||
body (dict): 包含消息的请求体。
|
||||
user (Optional[dict]): 用户信息。
|
||||
|
||||
返回:
|
||||
dict: 包含优化后提示词的已修改请求体。
|
||||
"""
|
||||
print(f"pipe:{__name__}")
|
||||
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
user_message_content = ""
|
||||
user_message_index = -1
|
||||
|
||||
# 找到最后一条用户消息
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
content = messages[i].get("content", "")
|
||||
# 处理内容为数组的情况(多模态消息)
|
||||
if isinstance(content, list):
|
||||
# 从数组中提取所有文本内容
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
user_message_content = "".join(text_parts)
|
||||
elif isinstance(content, str):
|
||||
user_message_content = content
|
||||
|
||||
user_message_index = i
|
||||
break
|
||||
|
||||
if user_message_index == -1:
|
||||
return body
|
||||
|
||||
# 检查是否为MoE汇总请求
|
||||
if isinstance(user_message_content, str) and user_message_content.startswith(
|
||||
"You have been provided with a set of responses from various models to the latest user query"
|
||||
):
|
||||
print("检测到MoE汇总请求,正在更改提示词。")
|
||||
|
||||
# 1. 提取原始查询
|
||||
query_start_phrase = 'the latest user query: "'
|
||||
query_end_phrase = '"\n\nYour task is to'
|
||||
start_index = user_message_content.find(query_start_phrase)
|
||||
end_index = user_message_content.find(query_end_phrase)
|
||||
|
||||
original_query = ""
|
||||
if start_index != -1 and end_index != -1:
|
||||
original_query = user_message_content[
|
||||
start_index + len(query_start_phrase) : end_index
|
||||
]
|
||||
|
||||
# 2. 提取各个模型的响应
|
||||
responses_start_phrase = "Responses from models: "
|
||||
responses_start_index = user_message_content.find(responses_start_phrase)
|
||||
|
||||
responses_text = ""
|
||||
if responses_start_index != -1:
|
||||
responses_text = user_message_content[
|
||||
responses_start_index + len(responses_start_phrase) :
|
||||
]
|
||||
|
||||
# 使用三重双引号作为分隔符来提取响应
|
||||
responses = [
|
||||
part.strip() for part in responses_text.split('"""') if part.strip()
|
||||
]
|
||||
|
||||
# 3. 动态构建模型响应部分
|
||||
responses_section = ""
|
||||
for i, response in enumerate(responses):
|
||||
responses_section += f'''"""
|
||||
[第 {i + 1} 个模型的完整回答]
|
||||
{response}
|
||||
"""
|
||||
'''
|
||||
|
||||
# 4. 构建新的提示词
|
||||
merge_prompt = f'''# 角色定位
|
||||
你是一位经验丰富的首席分析师,正在处理来自多个独立 AI 专家团队对同一问题的分析报告。你的任务是将这些报告进行深度整合、批判性分析,并提炼出一份结构清晰、洞察深刻、对决策者极具价值的综合报告。
|
||||
|
||||
# 原始用户问题
|
||||
{original_query}
|
||||
|
||||
# 输入格式说明 ⚠️ 重要
|
||||
各模型的响应已通过 """ (三重引号)分隔符准确识别和分离。系统已将不同模型的回答分别提取,你现在需要基于以下分离后的内容进行分析。
|
||||
|
||||
**已分离的模型响应**:
|
||||
{responses_section}
|
||||
# 核心任务
|
||||
请勿简单地复制或拼接原始报告。你需要运用你的专业分析能力,完成以下步骤:
|
||||
|
||||
## 1. 信息解析与评估 (Analysis & Evaluation)
|
||||
- **准确分隔**: 已根据 """ 分隔符,准确识别每个模型的回答边界。
|
||||
- **可信度评估**: 批判性地审视每份报告,识别其中可能存在的偏见、错误或不一致之处。
|
||||
- **逻辑梳理**: 理清每份报告的核心论点、支撑论据和推理链条。
|
||||
|
||||
## 2. 核心洞察提炼 (Insight Extraction)
|
||||
- **识别共识**: 找出所有报告中共同提及、高度一致的观点或建议。这通常是问题的核心事实或最稳健的策略。
|
||||
- **突出差异**: 明确指出各报告在视角、方法、预测或结论上的关键分歧点。这些分歧往往蕴含着重要的战略考量。
|
||||
- **捕捉亮点**: 挖掘单个报告中独有的、具有创新性或深刻性的见解,这些"闪光点"可能是关键的差异化优势。
|
||||
|
||||
## 3. 综合报告撰写 (Synthesis)
|
||||
基于以上分析,生成一份包含以下结构的综合报告:
|
||||
|
||||
### **【核心共识】**
|
||||
- 用清晰的要点列出所有模型一致认同的关键信息或建议。
|
||||
- 标注覆盖范围(如"所有模型均同意"或"多数模型提及")。
|
||||
|
||||
### **【关键分歧】**
|
||||
- 清晰地对比不同模型在哪些核心问题上持有不同观点。
|
||||
- 用序号或描述性语言标识不同的观点阵营(如"观点 A 与观点 B 的分歧"或"方案 1 vs 方案 2")。
|
||||
- 简要说明其原因或侧重点的差异。
|
||||
|
||||
### **【独特洞察】**
|
||||
- 提炼并呈现那些仅在单个报告中出现,但极具价值的独特建议或视角。
|
||||
- 用"某个模型提出"或"另一视角"等中立表述,避免因缺少显式来源标记而造成的混淆。
|
||||
|
||||
### **【综合分析与建议】**
|
||||
- **整合**: 基于共识、差异和亮点,提供一个全面、平衡、且经过你专业判断优化的最终分析。
|
||||
- **建议**: 如果原始指令是寻求方案或策略,这里应提出一个或多个融合了各方优势的、可执行的建议。
|
||||
|
||||
# 格式要求
|
||||
- 语言精炼、逻辑清晰、结构分明。
|
||||
- 使用加粗、列表、标题等格式,确保报告易于阅读和理解。
|
||||
- 由于缺少显式的模型标识,**在呈现差异化观点时,使用描述性或序号化的方式**(如"第一种观点""另一个视角")而非具体的模型名称。
|
||||
- 始终以"为用户提供最高价值的决策依据"为目标。
|
||||
|
||||
# 输出结构示例
|
||||
根据以上要求,你的输出应该呈现如下结构:
|
||||
|
||||
## 【核心共识】
|
||||
✓ [共识观点 1] —— 所有模型均同意
|
||||
✓ [共识观点 2] —— 多数模型同意
|
||||
|
||||
## 【关键分歧】
|
||||
⚡ **在[议题]上的分歧**:
|
||||
- 观点阵营 A: ...
|
||||
- 观点阵营 B: ...
|
||||
- 观点阵营 C: ...
|
||||
|
||||
## 【独特洞察】
|
||||
💡 [某个模型独有的深刻观点]: ...
|
||||
💡 [另一个模型的创新视角]: ...
|
||||
|
||||
## 【综合分析与建议】
|
||||
基于以上分析,推荐方案/策略: ...
|
||||
'''
|
||||
|
||||
# 5. 替换原始消息内容
|
||||
body["messages"][user_message_index]["content"] = merge_prompt
|
||||
print("提示词已成功动态替换。")
|
||||
|
||||
return body
|
||||
1
plugins/pipelines/moe_prompt_refiner/valves.json
Normal file
1
plugins/pipelines/moe_prompt_refiner/valves.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
0
plugins/pipelines/requirements.txt
Normal file
0
plugins/pipelines/requirements.txt
Normal file
60
plugins/pipes/README.md
Normal file
60
plugins/pipes/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Pipes
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
Pipes process and enhance LLM responses after they are generated and before they are displayed to the user. This directory contains various pipe plugins that can be used to extend OpenWebUI functionality.
|
||||
|
||||
## 📋 Pipe Plugins List
|
||||
|
||||
| Plugin Name | Description | Documentation |
|
||||
| :--- | :--- | :--- |
|
||||
| **Example Pipe** | A template/example for creating pipe plugins | [English](./example-pipe/README.md) / [中文](./example-pipe/README_CN.md) |
|
||||
| **AI Agent Pipe** | Transforms AI responses into complete agent workflows with multiple thinking rounds and tool calls | [English](./ai-agent-pipe/README.md) / [中文](./ai-agent-pipe/README_CN.md) |
|
||||
|
||||
## 🎯 What are Pipe Plugins?
|
||||
|
||||
Pipe plugins process the output from the LLM and can:
|
||||
|
||||
- Format responses (convert to markdown, JSON, tables, etc.)
|
||||
- Enhance responses with additional information
|
||||
- Translate or transform content
|
||||
- Filter or modify content before display
|
||||
- Add watermarks or metadata
|
||||
- Integrate with external services
|
||||
|
||||
Pipes are executed after the LLM generates a response but before the user sees it.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installing a Pipe Plugin
|
||||
|
||||
1. Download the plugin file (`.py`) to your local machine
|
||||
2. Open OpenWebUI Admin Settings and find the "Plugins" section
|
||||
3. Select the "Pipes" type
|
||||
4. Upload the downloaded file
|
||||
5. Refresh the page and enable the pipe in your chat settings
|
||||
6. The pipe will be applied to all subsequent LLM responses
|
||||
|
||||
## 📖 Development Guide
|
||||
|
||||
When adding a new pipe plugin, please follow these steps:
|
||||
|
||||
1. **Create Plugin Directory**: Create a new folder under `plugins/pipes/` (e.g., `my_pipe/`)
|
||||
2. **Write Plugin Code**: Create a `.py` file with clear documentation of functionality
|
||||
3. **Write Documentation**:
|
||||
- Create `README.md` (English version)
|
||||
- Create `README_CN.md` (Chinese version)
|
||||
- Include: feature description, configuration, usage examples, and troubleshooting
|
||||
4. **Update This List**: Add your plugin to the table above
|
||||
|
||||
## ⚙️ Best Practices for Pipe Development
|
||||
|
||||
- **Non-blocking Operations**: Keep pipe processing fast to avoid UI delays
|
||||
- **Error Handling**: Gracefully handle errors without breaking the response
|
||||
- **Configuration**: Make pipes configurable for different use cases
|
||||
- **Performance**: Test with large responses to ensure efficiency
|
||||
- **Documentation**: Provide clear examples and troubleshooting guides
|
||||
|
||||
---
|
||||
|
||||
> **Contributor Note**: We welcome contributions of new pipe plugins! Please provide clear and complete documentation for each new plugin, including features, configuration, usage examples, and troubleshooting guides.
|
||||
60
plugins/pipes/README_CN.md
Normal file
60
plugins/pipes/README_CN.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Pipes(管道插件)
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
管道插件(Pipes)在 LLM 生成响应后、展示给用户前对响应进行处理和增强。此目录包含可用于扩展 OpenWebUI 功能的各种管道插件。
|
||||
|
||||
## 📋 管道插件列表
|
||||
|
||||
| 插件名称 | 描述 | 文档 |
|
||||
| :--- | :--- | :--- |
|
||||
| **示例管道** | 创建管道插件的模板/示例 | [中文](./example-pipe/README_CN.md) / [English](./example-pipe/README.md) |
|
||||
| **AI代理管道** | 将AI响应转换为完整的代理工作流程,包含多轮思考和工具调用 | [中文](./ai-agent-pipe/README_CN.md) / [English](./ai-agent-pipe/README.md) |
|
||||
|
||||
## 🎯 什么是管道插件?
|
||||
|
||||
管道插件对 LLM 的输出进行处理,可以:
|
||||
|
||||
- 格式化响应(转换为 Markdown、JSON、表格等)
|
||||
- 用附加信息增强响应
|
||||
- 翻译或转换内容
|
||||
- 在显示前过滤或修改内容
|
||||
- 添加水印或元数据
|
||||
- 与外部服务集成
|
||||
|
||||
管道在 LLM 生成响应之后、用户看到响应之前执行。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装管道插件
|
||||
|
||||
1. 将插件文件(`.py`)下载到本地
|
||||
2. 在 OpenWebUI 管理员设置中,找到"Plugins"部分
|
||||
3. 选择"Pipes"类型
|
||||
4. 上传下载的文件
|
||||
5. 刷新页面并在聊天设置中启用管道
|
||||
6. 该管道将应用于所有后续的 LLM 响应
|
||||
|
||||
## 📖 开发指南
|
||||
|
||||
添加新管道插件时,请遵循以下步骤:
|
||||
|
||||
1. **创建插件目录**:在 `plugins/pipes/` 下创建新文件夹(例如 `my_pipe/`)
|
||||
2. **编写插件代码**:创建 `.py` 文件,清晰记录功能说明
|
||||
3. **编写文档**:
|
||||
- 创建 `README.md`(英文版)
|
||||
- 创建 `README_CN.md`(中文版)
|
||||
- 包含:功能说明、配置方法、使用示例和故障排除
|
||||
4. **更新此列表**:在上述表格中添加您的插件
|
||||
|
||||
## ⚙️ 管道开发最佳实践
|
||||
|
||||
- **非阻塞操作**:保持管道处理快速以避免 UI 延迟
|
||||
- **错误处理**:优雅地处理错误而不破坏响应
|
||||
- **配置灵活性**:使管道可配置以适应不同用例
|
||||
- **性能优化**:使用大型响应测试以确保效率
|
||||
- **文档完整**:提供清晰的示例和故障排除指南
|
||||
|
||||
---
|
||||
|
||||
> **贡献者注意**:我们欢迎贡献新的管道插件!请为每个新增插件提供清晰完整的文档,包括功能说明、配置方法、使用示例和故障排除指南。
|
||||
54
plugins/pipes/gemini_mainfold/README.md
Normal file
54
plugins/pipes/gemini_mainfold/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Example Pipe Plugin
|
||||
|
||||
**Author:** OpenWebUI Community | **Version:** 1.0.0 | **License:** MIT
|
||||
|
||||
This is a template/example for creating Pipe plugins in OpenWebUI.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Pipes are plugins that process and enhance LLM responses after they are generated and before they are displayed to the user.
|
||||
|
||||
## Core Features
|
||||
|
||||
- ✅ **Response Processing**: Modify or enhance LLM output
|
||||
- ✅ **Format Conversion**: Convert responses to different formats
|
||||
- ✅ **Content Filtering**: Filter or sanitize content
|
||||
- ✅ **Integration**: Connect with external services
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the `.py` file from this directory
|
||||
2. Open OpenWebUI Admin Settings → Plugins
|
||||
3. Select "Pipes" type
|
||||
4. Upload the file
|
||||
5. Refresh the page
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure the pipe parameters in your chat settings as needed.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
Once enabled, this pipe will automatically process all LLM responses.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check the logs for any errors during pipe execution
|
||||
- Ensure the pipe is properly configured
|
||||
- Verify the pipe is enabled in chat settings
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to create your own pipe plugins! Follow the structure and documentation guidelines in this template.
|
||||
54
plugins/pipes/gemini_mainfold/README_CN.md
Normal file
54
plugins/pipes/gemini_mainfold/README_CN.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 示例管道插件
|
||||
|
||||
**作者:** OpenWebUI 社区 | **版本:** 1.0.0 | **许可证:** MIT
|
||||
|
||||
这是在 OpenWebUI 中创建管道插件的模板/示例。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
管道是在 LLM 生成响应后、显示给用户前对响应进行处理和增强的插件。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- ✅ **响应处理**: 修改或增强 LLM 输出
|
||||
- ✅ **格式转换**: 将响应转换为不同格式
|
||||
- ✅ **内容过滤**: 过滤或清理内容
|
||||
- ✅ **集成**: 与外部服务连接
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
1. 从此目录下载 `.py` 文件
|
||||
2. 打开 OpenWebUI 管理员设置 → 插件(Plugins)
|
||||
3. 选择"Pipes"类型
|
||||
4. 上传文件
|
||||
5. 刷新页面
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
根据需要在聊天设置中配置管道参数。
|
||||
|
||||
---
|
||||
|
||||
## 使用
|
||||
|
||||
启用后,该管道将自动处理所有 LLM 响应。
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
- 查看日志了解管道执行过程中的任何错误
|
||||
- 确保管道配置正确
|
||||
- 验证管道在聊天设置中已启用
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎创建您自己的管道插件!请遵循此模板中的结构和文档指南。
|
||||
3382
plugins/pipes/gemini_mainfold/gemini_manifold.py
Normal file
3382
plugins/pipes/gemini_mainfold/gemini_manifold.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user