feat: 新增插件系统、多种插件类型、开发指南及多语言文档。

This commit is contained in:
fujie
2025-12-20 12:34:49 +08:00
commit eaa6319991
74 changed files with 28409 additions and 0 deletions

124
plugins/README.md Normal file
View 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
View 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
View 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.

View 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**:确保消息流正确传递
---
> **贡献者注意**:为了确保项目质量,请为每个新增插件提供清晰完整的文档,包括功能说明、配置方法、使用示例和故障排除指南。参考上述通用功能开发您的插件。

View 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.

View File

@@ -0,0 +1,15 @@
# 导出为 Excel
此插件允许你直接从聊天界面将对话历史导出为 Excel (.xlsx) 文件。
## 功能特点
- **一键导出**:在聊天界面添加“导出为 Excel”按钮。
- **自动表头提取**:智能识别聊天内容中的表格标题。
- **多表支持**:支持处理单次对话中的多个表格。
## 使用方法
1. 安装插件。
2. 在任意对话中,点击“导出为 Excel”按钮。
3. 文件将自动下载到你的设备。

View 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)}")

View 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)}")

View 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.

View File

@@ -0,0 +1,15 @@
# 闪记卡 (Flash Card)
快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类,助力高效学习。
## 功能特点
- **即时生成**:将任何文本转化为结构化的记忆卡片。
- **要点提取**:自动识别核心概念。
- **视觉设计**:生成视觉精美的 HTML 卡片。
## 使用方法
1. 安装插件。
2. 发送文本到聊天框。
3. 插件将分析文本并生成一张闪记卡。

View 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

View 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

View 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/)

View 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/)

View 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

View 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

View 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).

View File

@@ -0,0 +1,15 @@
# 深度阅读与摘要 (Deep Reading & Summary)
一个强大的长文本分析工具,用于生成详细摘要、关键信息点和可执行的行动建议。
## 功能特点
- **深度分析**:超越简单的总结,深入理解核心信息。
- **关键点提取**:识别并列出最重要的信息点。
- **行动建议**:基于文本内容提供切实可行的建议。
## 使用方法
1. 安装插件。
2. 发送长文本或文章到聊天框。
3. 点击“精读”按钮(或通过命令触发)。

View 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>&copy; {{ 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

View 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>&copy; {{ 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
View 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.

View 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` 或其他能够产生多模型回答的工具,以便此过滤器能够检测到多模型历史。
* 此过滤器不会增加额外的模型调用,因此不会显著增加延迟或成本。它只是对现有历史数据进行格式化和注入。

View File

@@ -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.

View File

@@ -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)

View File

@@ -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` 的值。

View 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-1413条
生成: 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 消耗显著降低
- ✅ 历史信息连贯保存
- ✅ 灵活的配置选项

File diff suppressed because it is too large Load Diff

View 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 判断是否触发后台摘要任务。

View File

@@ -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

View 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

View 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

View File

@@ -0,0 +1 @@
{}

View File

60
plugins/pipes/README.md Normal file
View 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.

View 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 延迟
- **错误处理**:优雅地处理错误而不破坏响应
- **配置灵活性**:使管道可配置以适应不同用例
- **性能优化**:使用大型响应测试以确保效率
- **文档完整**:提供清晰的示例和故障排除指南
---
> **贡献者注意**:我们欢迎贡献新的管道插件!请为每个新增插件提供清晰完整的文档,包括功能说明、配置方法、使用示例和故障排除指南。

View 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.

View File

@@ -0,0 +1,54 @@
# 示例管道插件
**作者:** OpenWebUI 社区 | **版本:** 1.0.0 | **许可证:** MIT
这是在 OpenWebUI 中创建管道插件的模板/示例。
---
## 概述
管道是在 LLM 生成响应后、显示给用户前对响应进行处理和增强的插件。
## 核心特性
-**响应处理**: 修改或增强 LLM 输出
-**格式转换**: 将响应转换为不同格式
-**内容过滤**: 过滤或清理内容
-**集成**: 与外部服务连接
---
## 安装
1. 从此目录下载 `.py` 文件
2. 打开 OpenWebUI 管理员设置 → 插件Plugins
3. 选择"Pipes"类型
4. 上传文件
5. 刷新页面
---
## 配置
根据需要在聊天设置中配置管道参数。
---
## 使用
启用后,该管道将自动处理所有 LLM 响应。
---
## 故障排除
- 查看日志了解管道执行过程中的任何错误
- 确保管道配置正确
- 验证管道在聊天设置中已启用
---
## 贡献
欢迎创建您自己的管道插件!请遵循此模板中的结构和文档指南。

File diff suppressed because it is too large Load Diff