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

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