feat: 新增插件系统、多种插件类型、开发指南及多语言文档。
This commit is contained in:
227
plugins/actions/README.md
Normal file
227
plugins/actions/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Actions (Action Plugins)
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
Action plugins allow you to define custom functionalities that can be triggered from chat. This directory contains various action plugins that can be used to extend OpenWebUI functionality.
|
||||
|
||||
## 📋 Action Plugins List
|
||||
|
||||
| Plugin Name | Description | Version | Documentation |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Smart Mind Map** | Intelligently analyzes text content and generates interactive mind maps | 0.7.2 | [English](./smart-mind-map/README.md) / [中文](./smart-mind-map/README_CN.md) |
|
||||
| **Flash Card (闪记卡)** | Quickly generates beautiful learning memory cards, perfect for studying and quick memorization | 0.2.0 | [English](./knowledge-card/README.md) / [中文](./knowledge-card/README_CN.md) |
|
||||
|
||||
## 🎯 What are Action Plugins?
|
||||
|
||||
Action plugins typically used for:
|
||||
|
||||
- Generating specific output formats (such as mind maps, charts, tables, etc.)
|
||||
- Interacting with external APIs or services
|
||||
- Performing data transformations and processing
|
||||
- Saving or exporting content to files
|
||||
- Creating interactive visualizations
|
||||
- Automating complex workflows
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installing an Action Plugin
|
||||
|
||||
1. Download the plugin file (`.py`) to your local machine
|
||||
2. Open OpenWebUI Admin Settings and find the "Plugins" section
|
||||
3. Select the "Actions" type
|
||||
4. Upload the downloaded file
|
||||
5. Refresh the page and enable the plugin in chat settings
|
||||
6. Use the plugin by selecting it from the available actions in chat
|
||||
|
||||
## 📖 Development Guide
|
||||
|
||||
### Adding a New Action Plugin
|
||||
|
||||
When adding a new action plugin, please follow these steps:
|
||||
|
||||
1. **Create Plugin Directory**: Create a new folder under `plugins/actions/` (e.g., `my_action/`)
|
||||
2. **Write Plugin Code**: Create a `.py` file with clear documentation of functionality
|
||||
3. **Write Documentation**:
|
||||
- Create `README.md` (English version)
|
||||
- Create `README_CN.md` (Chinese version)
|
||||
- Include: feature description, configuration, usage examples, and troubleshooting
|
||||
4. **Update This List**: Add your plugin to the table above
|
||||
|
||||
### Open WebUI Plugin Development Common Features
|
||||
|
||||
When developing Action plugins, you can use the following standard features provided by Open WebUI:
|
||||
|
||||
#### 1. **Plugin Metadata Definition**
|
||||
|
||||
```python
|
||||
"""
|
||||
title: Plugin Name
|
||||
icon_url: data:image/svg+xml;base64,... # Plugin icon (Base64 encoded SVG)
|
||||
version: 1.0.0
|
||||
description: Plugin functionality description
|
||||
"""
|
||||
```
|
||||
|
||||
#### 2. **Valves Configuration System**
|
||||
|
||||
Use Pydantic to define configurable parameters that users can adjust dynamically in the UI:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show status updates"
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
description="API key"
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. **Standard Action Class Structure**
|
||||
|
||||
```python
|
||||
class Action:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
# Plugin logic
|
||||
return body
|
||||
```
|
||||
|
||||
#### 4. **Getting User Information**
|
||||
|
||||
```python
|
||||
# Supports both dictionary and list formats
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
```
|
||||
|
||||
#### 5. **Event Emitter (event_emitter)**
|
||||
|
||||
**Sending notification messages:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info", # info/warning/error/success
|
||||
"content": "Message content"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Sending status updates:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Status description",
|
||||
"done": False, # True when completed
|
||||
"hidden": False # True to hide
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 6. **Calling Built-in LLM**
|
||||
|
||||
```python
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Get user object
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
# Build LLM request
|
||||
llm_payload = {
|
||||
"model": "model-id",
|
||||
"messages": [
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "User input"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# Call LLM
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
```
|
||||
|
||||
#### 7. **Handling Message Body**
|
||||
|
||||
```python
|
||||
# Read messages
|
||||
messages = body.get("messages")
|
||||
user_message = messages[-1]["content"]
|
||||
|
||||
# Modify messages
|
||||
body["messages"][-1]["content"] = f"{user_message}\n\nAdditional content"
|
||||
|
||||
# Return modified body
|
||||
return body
|
||||
```
|
||||
|
||||
#### 8. **Embedding HTML Content**
|
||||
|
||||
```python
|
||||
html_content = "<div>Interactive content</div>"
|
||||
html_embed_tag = f"```html\n{html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{text}\n\n{html_embed_tag}"
|
||||
```
|
||||
|
||||
#### 9. **Async Processing**
|
||||
|
||||
All plugin methods must be asynchronous:
|
||||
|
||||
```python
|
||||
async def action(...):
|
||||
await __event_emitter__(...)
|
||||
result = await some_async_function()
|
||||
return result
|
||||
```
|
||||
|
||||
#### 10. **Error Handling and Logging**
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
# Plugin logic
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {str(e)}", exc_info=True)
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"Operation failed: {str(e)}"}
|
||||
})
|
||||
```
|
||||
|
||||
### Development Best Practices
|
||||
|
||||
1. **Use Valves Configuration**: Allow users to customize plugin behavior
|
||||
2. **Provide Real-time Feedback**: Use event emitter to inform users of progress
|
||||
3. **Graceful Error Handling**: Catch exceptions and provide friendly messages
|
||||
4. **Support Multiple Languages**: Get language preference from `__user__`
|
||||
5. **Logging**: Record key operations and errors for debugging
|
||||
6. **Validate Input**: Check required parameters and data formats
|
||||
7. **Return Complete Body**: Ensure message flow is properly passed
|
||||
|
||||
---
|
||||
|
||||
> **Contributor Note**: To ensure project quality, please provide clear and complete documentation for each new plugin, including features, configuration, usage examples, and troubleshooting guides. Refer to the common features above when developing your plugins.
|
||||
226
plugins/actions/README_CN.md
Normal file
226
plugins/actions/README_CN.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Actions(动作插件)
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
动作插件(Actions)允许您定义可以从聊天中触发的自定义功能。此目录包含可用于扩展 OpenWebUI 功能的各种动作插件。
|
||||
|
||||
## 📋 动作插件列表
|
||||
|
||||
| 插件名称 | 描述 | 版本 | 文档 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **智绘心图** | 智能分析文本内容,生成交互式思维导图 | 0.7.2 | [中文](./smart-mind-map/README_CN.md) / [English](./smart-mind-map/README.md) |
|
||||
|
||||
## 🎯 什么是动作插件?
|
||||
|
||||
动作插件通常用于:
|
||||
|
||||
- 生成特定格式的输出(如思维导图、图表、表格等)
|
||||
- 与外部 API 或服务交互
|
||||
- 执行数据转换和处理
|
||||
- 保存或导出内容到文件
|
||||
- 创建交互式可视化
|
||||
- 自动化复杂工作流程
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装动作插件
|
||||
|
||||
1. 将插件文件(`.py`)下载到本地
|
||||
2. 在 OpenWebUI 管理员设置中,找到"Plugins"部分
|
||||
3. 选择"Actions"类型
|
||||
4. 上传下载的文件
|
||||
5. 刷新页面并在聊天设置中启用插件
|
||||
6. 在聊天中从可用动作中选择使用该插件
|
||||
|
||||
## 📖 开发指南
|
||||
|
||||
### 添加新动作插件
|
||||
|
||||
添加新动作插件时,请遵循以下步骤:
|
||||
|
||||
1. **创建插件目录**:在 `plugins/actions/` 下创建新文件夹(例如 `my_action/`)
|
||||
2. **编写插件代码**:创建 `.py` 文件,清晰记录功能说明
|
||||
3. **编写文档**:
|
||||
- 创建 `README.md`(英文版)
|
||||
- 创建 `README_CN.md`(中文版)
|
||||
- 包含:功能说明、配置方法、使用示例和故障排除
|
||||
4. **更新此列表**:在上述表格中添加您的插件
|
||||
|
||||
### Open WebUI 插件开发通用功能
|
||||
|
||||
开发 Action 插件时,可以使用以下 Open WebUI 提供的标准功能:
|
||||
|
||||
#### 1. **插件元数据定义**
|
||||
|
||||
```python
|
||||
"""
|
||||
title: 插件名称
|
||||
icon_url: data:image/svg+xml;base64,... # 插件图标(Base64编码的SVG)
|
||||
version: 1.0.0
|
||||
description: 插件功能描述
|
||||
"""
|
||||
```
|
||||
|
||||
#### 2. **Valves 配置系统**
|
||||
|
||||
使用 Pydantic 定义可配置参数,用户可在 UI 界面动态调整:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True,
|
||||
description="是否显示状态更新"
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
description="API密钥"
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. **标准 Action 类结构**
|
||||
|
||||
```python
|
||||
class Action:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
# 插件逻辑
|
||||
return body
|
||||
```
|
||||
|
||||
#### 4. **获取用户信息**
|
||||
|
||||
```python
|
||||
# 支持字典和列表两种格式
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
```
|
||||
|
||||
#### 5. **事件发射器 (event_emitter)**
|
||||
|
||||
**发送通知消息:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info", # info/warning/error/success
|
||||
"content": "消息内容"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**发送状态更新:**
|
||||
|
||||
```python
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "状态描述",
|
||||
"done": False, # True表示完成
|
||||
"hidden": False # True表示隐藏
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### 6. **调用内置 LLM**
|
||||
|
||||
```python
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# 获取用户对象
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
# 构建 LLM 请求
|
||||
llm_payload = {
|
||||
"model": "model-id",
|
||||
"messages": [
|
||||
{"role": "system", "content": "系统提示词"},
|
||||
{"role": "user", "content": "用户输入"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# 调用 LLM
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
```
|
||||
|
||||
#### 7. **处理消息体 (body)**
|
||||
|
||||
```python
|
||||
# 读取消息
|
||||
messages = body.get("messages")
|
||||
user_message = messages[-1]["content"]
|
||||
|
||||
# 修改消息
|
||||
body["messages"][-1]["content"] = f"{user_message}\n\n新增内容"
|
||||
|
||||
# 返回修改后的body
|
||||
return body
|
||||
```
|
||||
|
||||
#### 8. **嵌入 HTML 内容**
|
||||
|
||||
```python
|
||||
html_content = "<div>交互式内容</div>"
|
||||
html_embed_tag = f"```html\n{html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{text}\n\n{html_embed_tag}"
|
||||
```
|
||||
|
||||
#### 9. **异步处理**
|
||||
|
||||
所有插件方法必须是异步的:
|
||||
|
||||
```python
|
||||
async def action(...):
|
||||
await __event_emitter__(...)
|
||||
result = await some_async_function()
|
||||
return result
|
||||
```
|
||||
|
||||
#### 10. **错误处理和日志**
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
# 插件逻辑
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"错误: {str(e)}", exc_info=True)
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": f"操作失败: {str(e)}"}
|
||||
})
|
||||
```
|
||||
|
||||
### 开发最佳实践
|
||||
|
||||
1. **使用 Valves 配置**:让用户可以自定义插件行为
|
||||
2. **提供实时反馈**:使用事件发射器告知用户进度
|
||||
3. **优雅的错误处理**:捕获异常并给出友好提示
|
||||
4. **支持多语言**:从 `__user__` 获取语言偏好
|
||||
5. **日志记录**:记录关键操作和错误,便于调试
|
||||
6. **验证输入**:检查必需参数和数据格式
|
||||
7. **返回完整的 body**:确保消息流正确传递
|
||||
|
||||
---
|
||||
|
||||
> **贡献者注意**:为了确保项目质量,请为每个新增插件提供清晰完整的文档,包括功能说明、配置方法、使用示例和故障排除指南。参考上述通用功能开发您的插件。
|
||||
15
plugins/actions/export_to_excel/README.md
Normal file
15
plugins/actions/export_to_excel/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Export to Excel
|
||||
|
||||
This plugin allows you to export your chat history to an Excel (.xlsx) file directly from the chat interface.
|
||||
|
||||
## Features
|
||||
|
||||
- **One-Click Export**: Adds an "Export to Excel" button to the chat.
|
||||
- **Automatic Header Extraction**: Intelligently identifies table headers from the chat content.
|
||||
- **Multi-Table Support**: Handles multiple tables within a single chat session.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. In any chat, click the "Export to Excel" button.
|
||||
3. The file will be automatically downloaded to your device.
|
||||
15
plugins/actions/export_to_excel/README_CN.md
Normal file
15
plugins/actions/export_to_excel/README_CN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 导出为 Excel
|
||||
|
||||
此插件允许你直接从聊天界面将对话历史导出为 Excel (.xlsx) 文件。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **一键导出**:在聊天界面添加“导出为 Excel”按钮。
|
||||
- **自动表头提取**:智能识别聊天内容中的表格标题。
|
||||
- **多表支持**:支持处理单次对话中的多个表格。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 在任意对话中,点击“导出为 Excel”按钮。
|
||||
3. 文件将自动下载到你的设备。
|
||||
804
plugins/actions/export_to_excel/export_to_excel.py
Normal file
804
plugins/actions/export_to_excel/export_to_excel.py
Normal file
@@ -0,0 +1,804 @@
|
||||
"""
|
||||
title: 导出到Excel
|
||||
author: Fu-Jie
|
||||
description: 从最后一条AI回答消息中提取Markdown表格到Excel文件,并在浏览器中触发下载。支持多表并自动根据标题命名
|
||||
icon_url: data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPgo8c3ZnIHdpZHRoPSI4MDBweCIgaGVpZ2h0PSI4MDBweCIgdmlld0JveD0iMCAtMS4yNyAxMTAuMDM3IDExMC4wMzciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTU3LjU1IDBoNy40MjV2MTBjMTIuNTEzIDAgMjUuMDI1LjAyNSAzNy41MzctLjAzOCAyLjExMy4wODcgNC40MzgtLjA2MiA2LjI3NSAxLjIgMS4yODcgMS44NSAxLjEzOCA0LjIgMS4yMjUgNi4zMjUtLjA2MiAyMS43LS4wMzcgNDMuMzg4LS4wMjQgNjUuMDc1LS4wNjIgMy42MzguMzM3IDcuMzUtLjQyNSAxMC45MzgtLjUgMi42LTMuNjI1IDIuNjYyLTUuNzEzIDIuNzUtMTIuOTUuMDM3LTI1LjkxMi0uMDI1LTM4Ljg3NSAwdjExLjI1aC03Ljc2M2MtMTkuMDUtMy40NjMtMzguMTM4LTYuNjYyLTU3LjIxMi0xMFYxMC4wMTNDMTkuMTg4IDYuNjc1IDM4LjM3NSAzLjM4OCA1Ny41NSAweiIgZmlsbD0iIzIwNzI0NSIvPjxwYXRoIGQ9Ik02NC45NzUgMTMuNzVoNDEuMjVWOTIuNWgtNDEuMjVWODVoMTB2LTguNzVoLTEwdi01aDEwVjYyLjVoLTEwdi01aDEwdi04Ljc1aC0xMHYtNWgxMFYzNWgtMTB2LTVoMTB2LTguNzVoLTEwdi03LjV6IiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTc5Ljk3NSAyMS4yNWgxNy41VjMwaC0xNy41di04Ljc1eiIgZmlsbD0iIzIwNzI0NSIvPjxwYXRoIGQ9Ik0zNy4wMjUgMzIuOTYyYzIuODI1LS4yIDUuNjYzLS4zNzUgOC41LS41MTJhMjYwNy4zNDQgMjYwNy4zNDQgMCAwIDEtMTAuMDg3IDIwLjQ4N2MzLjQzOCA3IDYuOTQ5IDEzLjk1IDEwLjM5OSAyMC45NSBhNzE2LjI4IDcxNi4yOCAwIDAgMS05LjAyNC0uNTc1Yy0yLjEyNS01LjIxMy00LjcxMy0xMC4yNS02LjIzOC0xNS43Yy0xLjY5OSA1LjA3NS00LjEyNSA5Ljg2Mi02LjA3NCAxNC44MzgtMi43MzgtLjAzOC01LjQ3Ni0uMTUtOC4yMTMtLjI2M0MxOS41IDY1LjkgMjIuNiA1OS41NjIgMjUuOTEyIDUzLjMxMmMtMi44MTItNi40MzgtNS45LTEyLjc1LTguOC0xOS4xNSAyLjc1LS4xNjMgNS41LS4zMjUgOC4yNS0uNDc1IDEuODYyIDQuODg4IDMuODk5IDkuNzEyIDUuNDM4IDE0LjcyNSAxLjY0OS01LjMxMiA0LjExMi0xMC4zMTIgNi4yMjUtMTUuNDV6IiBmaWxsPSIjZmZmZmZmIi8+PHBhdGggZD0iTTc5Ljk3NSAzNWgxNy41djguNzVoLTE3LjVWMzV6TTc5Ljk3NSA0OC43NWgxNy41djguNzVoLTE3LjV2LTguNzV6TTc5Ljk3NSA2Mi41aDE3LjV2OC43NWgtMTcuNVY2Mi41ek03OS45NzUgNzYuMjVoMTcuNVY4NWgtMTcuNXYtOC43NXoiIGZpbGw9IiMyMDcyNDUiLz48L3N2Zz4=
|
||||
version: 0.1.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import re
|
||||
import base64
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional, Callable, Awaitable, Any, List, Dict
|
||||
import datetime
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Action:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def _send_notification(self, emitter: Callable, type: str, content: str):
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": type, "content": content}}
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
):
|
||||
print(f"action:{__name__}")
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
if __event_emitter__:
|
||||
last_assistant_message = body["messages"][-1]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "正在保存到文件...", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
message_content = last_assistant_message["content"]
|
||||
tables = self.extract_tables_from_message(message_content)
|
||||
|
||||
if not tables:
|
||||
raise HTTPException(status_code=400, detail="未找到任何表格。")
|
||||
|
||||
# 获取动态文件名和sheet名称
|
||||
workbook_name, sheet_names = self.generate_names_from_content(
|
||||
message_content, tables
|
||||
)
|
||||
|
||||
# 使用优化后的文件名生成逻辑
|
||||
current_datetime = datetime.datetime.now()
|
||||
formatted_date = current_datetime.strftime("%Y%m%d")
|
||||
|
||||
# 如果没找到标题则使用 user_yyyymmdd 格式
|
||||
if not workbook_name:
|
||||
workbook_name = f"{user_name}_{formatted_date}"
|
||||
|
||||
filename = f"{workbook_name}.xlsx"
|
||||
excel_file_path = os.path.join(
|
||||
"app", "backend", "data", "temp", filename
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(excel_file_path), exist_ok=True)
|
||||
|
||||
# 保存表格到Excel(使用符合中国规范的格式化功能)
|
||||
self.save_tables_to_excel_enhanced(tables, excel_file_path, sheet_names)
|
||||
|
||||
# 触发文件下载
|
||||
if __event_call__:
|
||||
with open(excel_file_path, "rb") as file:
|
||||
file_content = file.read()
|
||||
base64_blob = base64.b64encode(file_content).decode("utf-8")
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
try {{
|
||||
const base64Data = "{base64_blob}";
|
||||
const binaryData = atob(base64Data);
|
||||
const arrayBuffer = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {{
|
||||
arrayBuffer[i] = binaryData.charCodeAt(i);
|
||||
}}
|
||||
const blob = new Blob([arrayBuffer], {{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }});
|
||||
const filename = "{filename}";
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}} catch (error) {{
|
||||
console.error('触发下载时出错:', error);
|
||||
}}
|
||||
"""
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "输出已保存", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
if os.path.exists(excel_file_path):
|
||||
os.remove(excel_file_path)
|
||||
|
||||
return {"message": "下载事件已触发"}
|
||||
|
||||
except HTTPException as e:
|
||||
print(f"Error processing tables: {str(e.detail)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {e.detail}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
print(f"Error processing tables: {str(e)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {str(e)}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
|
||||
def extract_tables_from_message(self, message: str) -> List[Dict]:
|
||||
"""
|
||||
从消息文本中提取Markdown表格及位置信息
|
||||
返回结构: [{
|
||||
"data": 表格数据,
|
||||
"start_line": 起始行号,
|
||||
"end_line": 结束行号
|
||||
}]
|
||||
"""
|
||||
table_row_pattern = r"^\s*\|.*\|.*\s*$"
|
||||
rows = message.split("\n")
|
||||
tables = []
|
||||
current_table = []
|
||||
start_line = None
|
||||
current_line = 0
|
||||
|
||||
for row in rows:
|
||||
current_line += 1
|
||||
if re.search(table_row_pattern, row):
|
||||
if start_line is None:
|
||||
start_line = current_line # 记录表格起始行
|
||||
|
||||
# 处理表格行
|
||||
cells = [cell.strip() for cell in row.strip().strip("|").split("|")]
|
||||
|
||||
# 跳过分隔行
|
||||
is_separator_row = all(re.fullmatch(r"[:\-]+", cell) for cell in cells)
|
||||
if not is_separator_row:
|
||||
current_table.append(cells)
|
||||
elif current_table:
|
||||
# 表格结束
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line - 1,
|
||||
}
|
||||
)
|
||||
current_table = []
|
||||
start_line = None
|
||||
|
||||
# 处理最后一个表格
|
||||
if current_table:
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line,
|
||||
}
|
||||
)
|
||||
|
||||
return tables
|
||||
|
||||
def generate_names_from_content(self, content: str, tables: List[Dict]) -> tuple:
|
||||
"""
|
||||
根据内容生成工作簿名称和sheet名称
|
||||
- 忽略非空段落,只使用 markdown 标题 (h1-h6)。
|
||||
- 单表格: 使用最近的标题作为工作簿和工作表名。
|
||||
- 多表格: 使用文档第一个标题作为工作簿名,各表格最近的标题作为工作表名。
|
||||
- 默认命名:
|
||||
- 工作簿: 在主流程中处理 (user_yyyymmdd.xlsx)。
|
||||
- 工作表: 表1, 表2, ...
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
workbook_name = ""
|
||||
sheet_names = []
|
||||
all_headers = []
|
||||
|
||||
# 1. 查找文档中所有 h1-h6 标题及其位置
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^#{1,6}\s+", line):
|
||||
all_headers.append(
|
||||
{"text": re.sub(r"^#{1,6}\s+", "", line).strip(), "line_num": i}
|
||||
)
|
||||
|
||||
# 2. 为每个表格生成 sheet 名称
|
||||
for i, table in enumerate(tables):
|
||||
table_start_line = table["start_line"] - 1 # 转换为 0-based 索引
|
||||
closest_header_text = None
|
||||
|
||||
# 查找当前表格上方最近的标题
|
||||
candidate_headers = [
|
||||
h for h in all_headers if h["line_num"] < table_start_line
|
||||
]
|
||||
if candidate_headers:
|
||||
# 找到候选标题中行号最大的,即为最接近的
|
||||
closest_header = max(candidate_headers, key=lambda x: x["line_num"])
|
||||
closest_header_text = closest_header["text"]
|
||||
|
||||
if closest_header_text:
|
||||
# 清理并添加找到的标题
|
||||
sheet_names.append(self.clean_sheet_name(closest_header_text))
|
||||
else:
|
||||
# 如果找不到标题,使用默认名称 "表{i+1}"
|
||||
sheet_names.append(f"表{i+1}")
|
||||
|
||||
# 3. 根据表格数量确定工作簿名称
|
||||
if len(tables) == 1:
|
||||
# 单个表格: 使用其工作表名作为工作簿名 (前提是该名称不是默认的 "表1")
|
||||
if sheet_names[0] != "表1":
|
||||
workbook_name = sheet_names[0]
|
||||
elif len(tables) > 1:
|
||||
# 多个表格: 使用文档中的第一个标题作为工作簿名
|
||||
if all_headers:
|
||||
# 找到所有标题中行号最小的,即为第一个标题
|
||||
first_header = min(all_headers, key=lambda x: x["line_num"])
|
||||
workbook_name = first_header["text"]
|
||||
|
||||
# 4. 清理工作簿名称 (如果为空,主流程会使用默认名称)
|
||||
workbook_name = self.clean_filename(workbook_name) if workbook_name else ""
|
||||
|
||||
return workbook_name, sheet_names
|
||||
|
||||
def clean_filename(self, name: str) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()
|
||||
|
||||
def clean_sheet_name(self, name: str) -> str:
|
||||
"""清理sheet名称(限制31字符,去除非法字符)"""
|
||||
name = re.sub(r"[\\/*?[\]:]", "", name).strip()
|
||||
return name[:31] if len(name) > 31 else name
|
||||
|
||||
# ======================== 符合中国规范的格式化功能 ========================
|
||||
|
||||
def calculate_text_width(self, text: str) -> float:
|
||||
"""
|
||||
计算文本显示宽度,考虑中英文字符差异
|
||||
中文字符按2个单位计算,英文字符按1个单位计算
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
width = 0
|
||||
for char in str(text):
|
||||
# 判断是否为中文字符(包括中文标点)
|
||||
if "\u4e00" <= char <= "\u9fff" or "\u3000" <= char <= "\u303f":
|
||||
width += 2 # 中文字符占2个单位宽度
|
||||
else:
|
||||
width += 1 # 英文字符占1个单位宽度
|
||||
|
||||
return width
|
||||
|
||||
def calculate_text_height(self, text: str, max_width: int = 50) -> int:
|
||||
"""
|
||||
计算文本显示所需的行数
|
||||
根据换行符和文本长度计算
|
||||
"""
|
||||
if not text:
|
||||
return 1
|
||||
|
||||
text = str(text)
|
||||
# 计算换行符导致的行数
|
||||
explicit_lines = text.count("\n") + 1
|
||||
|
||||
# 计算因文本长度超出而需要的额外行数
|
||||
text_width = self.calculate_text_width(text.replace("\n", ""))
|
||||
wrapped_lines = max(
|
||||
1, int(text_width / max_width) + (1 if text_width % max_width > 0 else 0)
|
||||
)
|
||||
|
||||
return max(explicit_lines, wrapped_lines)
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def determine_content_type(self, header: str, values: list) -> str:
|
||||
"""
|
||||
根据表头和内容智能判断数据类型,符合中国官方表格规范
|
||||
返回: 'number', 'date', 'sequence', 'text'
|
||||
"""
|
||||
header_lower = str(header).lower().strip()
|
||||
|
||||
# 检查表头关键词
|
||||
number_keywords = [
|
||||
"数量",
|
||||
"金额",
|
||||
"价格",
|
||||
"费用",
|
||||
"成本",
|
||||
"收入",
|
||||
"支出",
|
||||
"总计",
|
||||
"小计",
|
||||
"百分比",
|
||||
"%",
|
||||
"比例",
|
||||
"率",
|
||||
"数值",
|
||||
"分数",
|
||||
"成绩",
|
||||
"得分",
|
||||
]
|
||||
date_keywords = ["日期", "时间", "年份", "月份", "时刻", "date", "time"]
|
||||
sequence_keywords = [
|
||||
"序号",
|
||||
"编号",
|
||||
"号码",
|
||||
"排序",
|
||||
"次序",
|
||||
"顺序",
|
||||
"id",
|
||||
"编码",
|
||||
]
|
||||
|
||||
# 检查表头
|
||||
for keyword in number_keywords:
|
||||
if keyword in header_lower:
|
||||
return "number"
|
||||
|
||||
for keyword in date_keywords:
|
||||
if keyword in header_lower:
|
||||
return "date"
|
||||
|
||||
for keyword in sequence_keywords:
|
||||
if keyword in header_lower:
|
||||
return "sequence"
|
||||
|
||||
# 检查数据内容
|
||||
if not values:
|
||||
return "text"
|
||||
|
||||
sample_values = [
|
||||
str(v).strip() for v in values[:10] if str(v).strip()
|
||||
] # 取前10个非空值作为样本
|
||||
if not sample_values:
|
||||
return "text"
|
||||
|
||||
numeric_count = 0
|
||||
date_count = 0
|
||||
sequence_count = 0
|
||||
|
||||
for value in sample_values:
|
||||
# 检查是否为数字
|
||||
try:
|
||||
float(
|
||||
value.replace(",", "")
|
||||
.replace(",", "")
|
||||
.replace("%", "")
|
||||
.replace("%", "")
|
||||
)
|
||||
numeric_count += 1
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 检查是否为日期格式
|
||||
date_patterns = [
|
||||
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?",
|
||||
r"\d{1,2}[-/]\d{1,2}[-/]\d{4}",
|
||||
r"\d{4}\d{2}\d{2}",
|
||||
]
|
||||
for pattern in date_patterns:
|
||||
if re.match(pattern, value):
|
||||
date_count += 1
|
||||
break
|
||||
|
||||
# 检查是否为序号格式
|
||||
if (
|
||||
re.match(r"^\d+$", value) and len(value) <= 4
|
||||
): # 纯数字且不超过4位,可能是序号
|
||||
sequence_count += 1
|
||||
|
||||
total_count = len(sample_values)
|
||||
|
||||
# 根据比例判断类型
|
||||
if numeric_count / total_count >= 0.7:
|
||||
return "number"
|
||||
elif date_count / total_count >= 0.7:
|
||||
return "date"
|
||||
elif sequence_count / total_count >= 0.8 and sequence_count > 2:
|
||||
return "sequence"
|
||||
else:
|
||||
return "text"
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def save_tables_to_excel_enhanced(
|
||||
self, tables: List[Dict], file_path: str, sheet_names: List[str]
|
||||
):
|
||||
"""
|
||||
符合中国官方表格规范的Excel保存功能
|
||||
"""
|
||||
try:
|
||||
with pd.ExcelWriter(file_path, engine="xlsxwriter") as writer:
|
||||
workbook = writer.book
|
||||
|
||||
# 定义表头样式 - 居中对齐(符合中国规范)
|
||||
header_format = workbook.add_format(
|
||||
{
|
||||
"bold": True,
|
||||
"font_size": 12,
|
||||
"font_color": "white",
|
||||
"bg_color": "#00abbd",
|
||||
"border": 1,
|
||||
"align": "center", # 表头居中
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 文本单元格样式 - 左对齐
|
||||
text_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "left", # 文本左对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 数值单元格样式 - 右对齐
|
||||
number_format = workbook.add_format(
|
||||
{"border": 1, "align": "right", "valign": "vcenter"} # 数值右对齐
|
||||
)
|
||||
|
||||
# 整数格式 - 右对齐
|
||||
integer_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"border": 1,
|
||||
"align": "right", # 整数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 小数格式 - 右对齐
|
||||
decimal_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"border": 1,
|
||||
"align": "right", # 小数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 日期格式 - 居中对齐
|
||||
date_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 日期居中对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 序号格式 - 居中对齐
|
||||
sequence_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 序号居中对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
try:
|
||||
table_data = table["data"]
|
||||
if not table_data or len(table_data) < 1:
|
||||
print(f"Skipping empty table at index {i}")
|
||||
continue
|
||||
|
||||
print(f"Processing table {i+1} with {len(table_data)} rows")
|
||||
|
||||
# 获取sheet名称
|
||||
sheet_name = (
|
||||
sheet_names[i] if i < len(sheet_names) else f"表{i+1}"
|
||||
)
|
||||
|
||||
# 创建DataFrame
|
||||
headers = [
|
||||
str(cell).strip()
|
||||
for cell in table_data[0]
|
||||
if str(cell).strip()
|
||||
]
|
||||
if not headers:
|
||||
print(f"Warning: No valid headers found for table {i+1}")
|
||||
headers = [f"列{j+1}" for j in range(len(table_data[0]))]
|
||||
|
||||
data_rows = []
|
||||
if len(table_data) > 1:
|
||||
max_cols = len(headers)
|
||||
for row in table_data[1:]:
|
||||
processed_row = []
|
||||
for j in range(max_cols):
|
||||
if j < len(row):
|
||||
processed_row.append(str(row[j]))
|
||||
else:
|
||||
processed_row.append("")
|
||||
data_rows.append(processed_row)
|
||||
df = pd.DataFrame(data_rows, columns=headers)
|
||||
else:
|
||||
df = pd.DataFrame(columns=headers)
|
||||
|
||||
print(f"DataFrame created with columns: {list(df.columns)}")
|
||||
|
||||
# 修复pandas FutureWarning - 使用try-except替代errors='ignore'
|
||||
for col in df.columns:
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 先写入数据(不包含表头)
|
||||
df.to_excel(
|
||||
writer,
|
||||
sheet_name=sheet_name,
|
||||
index=False,
|
||||
header=False,
|
||||
startrow=1,
|
||||
)
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# 应用符合中国规范的格式化
|
||||
self.apply_chinese_standard_formatting(
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing table {i+1}: {str(e)}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving Excel file: {str(e)}")
|
||||
raise
|
||||
|
||||
def apply_chinese_standard_formatting(
|
||||
self,
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
):
|
||||
"""
|
||||
应用符合中国官方表格规范的格式化
|
||||
- 表头: 居中对齐
|
||||
- 数值: 右对齐
|
||||
- 文本: 左对齐
|
||||
- 日期: 居中对齐
|
||||
- 序号: 居中对齐
|
||||
"""
|
||||
try:
|
||||
# 1. 写入表头(居中对齐)
|
||||
print(f"Writing headers with Chinese standard alignment: {headers}")
|
||||
for col_idx, header in enumerate(headers):
|
||||
if header and str(header).strip():
|
||||
worksheet.write(0, col_idx, str(header).strip(), header_format)
|
||||
else:
|
||||
default_header = f"列{col_idx+1}"
|
||||
worksheet.write(0, col_idx, default_header, header_format)
|
||||
|
||||
# 2. 分析每列的数据类型并应用相应格式
|
||||
column_types = {}
|
||||
for col_idx, column in enumerate(headers):
|
||||
if col_idx < len(df.columns):
|
||||
column_values = df.iloc[:, col_idx].tolist()
|
||||
column_types[col_idx] = self.determine_content_type(
|
||||
column, column_values
|
||||
)
|
||||
print(
|
||||
f"Column '{column}' determined as type: {column_types[col_idx]}"
|
||||
)
|
||||
else:
|
||||
column_types[col_idx] = "text"
|
||||
|
||||
# 3. 写入并格式化数据(根据类型使用不同对齐方式)
|
||||
for row_idx, row in df.iterrows():
|
||||
for col_idx, value in enumerate(row):
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
|
||||
# 根据内容类型选择格式
|
||||
if content_type == "number":
|
||||
# 数值类型 - 右对齐
|
||||
if pd.api.types.is_numeric_dtype(df.iloc[:, col_idx]):
|
||||
if pd.api.types.is_integer_dtype(df.iloc[:, col_idx]):
|
||||
current_format = integer_format
|
||||
else:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
if numeric_value.is_integer():
|
||||
current_format = integer_format
|
||||
value = int(numeric_value)
|
||||
else:
|
||||
current_format = decimal_format
|
||||
except (ValueError, TypeError):
|
||||
current_format = decimal_format
|
||||
else:
|
||||
current_format = number_format
|
||||
|
||||
elif content_type == "date":
|
||||
# 日期类型 - 居中对齐
|
||||
current_format = date_format
|
||||
|
||||
elif content_type == "sequence":
|
||||
# 序号类型 - 居中对齐
|
||||
current_format = sequence_format
|
||||
|
||||
else:
|
||||
# 文本类型 - 左对齐
|
||||
current_format = text_format
|
||||
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
|
||||
# 4. 自动调整列宽
|
||||
for col_idx, column in enumerate(headers):
|
||||
col_letter = self.get_column_letter(col_idx)
|
||||
|
||||
# 计算表头宽度
|
||||
header_width = self.calculate_text_width(str(column))
|
||||
|
||||
# 计算数据列的最大宽度
|
||||
max_data_width = 0
|
||||
if not df.empty and col_idx < len(df.columns):
|
||||
for value in df.iloc[:, col_idx]:
|
||||
value_width = self.calculate_text_width(str(value))
|
||||
max_data_width = max(max_data_width, value_width)
|
||||
|
||||
# 基础宽度:取表头和数据的最大宽度
|
||||
base_width = max(header_width, max_data_width)
|
||||
|
||||
# 根据内容类型调整宽度
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
if content_type == "sequence":
|
||||
# 序号列通常比较窄
|
||||
optimal_width = max(8, min(15, base_width + 2))
|
||||
elif content_type == "number":
|
||||
# 数值列需要额外空间显示数字
|
||||
optimal_width = max(12, min(25, base_width + 3))
|
||||
elif content_type == "date":
|
||||
# 日期列需要固定宽度
|
||||
optimal_width = max(15, min(20, base_width + 2))
|
||||
else:
|
||||
# 文本列根据内容调整
|
||||
if base_width <= 10:
|
||||
optimal_width = base_width + 3
|
||||
elif base_width <= 20:
|
||||
optimal_width = base_width + 4
|
||||
else:
|
||||
optimal_width = base_width + 5
|
||||
optimal_width = max(10, min(60, optimal_width))
|
||||
|
||||
worksheet.set_column(f"{col_letter}:{col_letter}", optimal_width)
|
||||
|
||||
# 5. 自动调整行高
|
||||
# 设置表头行高为35点
|
||||
worksheet.set_row(0, 35)
|
||||
|
||||
# 设置数据行行高
|
||||
for row_idx, row in df.iterrows():
|
||||
max_row_height = 20 # 中国表格规范建议的最小行高
|
||||
|
||||
for col_idx, value in enumerate(row):
|
||||
if col_idx < len(headers):
|
||||
col_width = min(
|
||||
60,
|
||||
max(
|
||||
10, self.calculate_text_width(str(headers[col_idx])) + 5
|
||||
),
|
||||
)
|
||||
else:
|
||||
col_width = 15
|
||||
|
||||
cell_lines = self.calculate_text_height(str(value), col_width)
|
||||
cell_height = cell_lines * 20 # 每行20点高度,符合中国规范
|
||||
|
||||
max_row_height = max(max_row_height, cell_height)
|
||||
|
||||
final_height = min(120, max_row_height)
|
||||
worksheet.set_row(row_idx + 1, final_height)
|
||||
|
||||
print(f"Successfully applied Chinese standard formatting")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to apply Chinese standard formatting: {str(e)}")
|
||||
# 降级到基础格式化
|
||||
self.apply_basic_formatting_fallback(worksheet, df)
|
||||
|
||||
def apply_basic_formatting_fallback(self, worksheet, df):
|
||||
"""
|
||||
基础格式化降级方案
|
||||
"""
|
||||
try:
|
||||
# 基础列宽调整
|
||||
for i, column in enumerate(df.columns):
|
||||
column_width = (
|
||||
max(
|
||||
len(str(column)),
|
||||
(df[column].astype(str).map(len).max() if not df.empty else 0),
|
||||
)
|
||||
+ 2
|
||||
)
|
||||
|
||||
col_letter = self.get_column_letter(i)
|
||||
worksheet.set_column(
|
||||
f"{col_letter}:{col_letter}", min(60, max(10, column_width))
|
||||
)
|
||||
|
||||
print("Applied basic formatting fallback")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Even basic formatting failed: {str(e)}")
|
||||
806
plugins/actions/export_to_excel/export_to_excel_cn.py
Normal file
806
plugins/actions/export_to_excel/export_to_excel_cn.py
Normal file
@@ -0,0 +1,806 @@
|
||||
"""
|
||||
title: 导出为 Excel
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.3.3
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNCAyaDZhMiAyIDAgMCAxIDIgMnYxNmEyIDIgMCAwIDEtMiAyaC02YTIgMiAwIDAgMS0yLTJ2LTVhMiAyIDAgMCAxLTItMnYtNSIvPjxwb2x5bGluZSBwb2ludHM9IjE0IDIgMTQgOCAyMCA4Ii8+PHBhdGggZD0iTTE2IDEzdjgiLz48cGF0aCBkPSJNOCAxM3Y4Ii8+PHBhdGggZD0iTTEyIDEzdjgiLz48cGF0aCBkPSJNMTYgMTdoLTgiLz48cGF0aCBkPSJNMTYgMjFoLTgiLz48cGF0aCBkPSJNMTYgMTNoLTgiLz48L3N2Zz4=
|
||||
description: 将当前对话历史导出为 Excel (.xlsx) 文件,支持自动提取表头。
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import re
|
||||
import base64
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from typing import Optional, Callable, Awaitable, Any, List, Dict
|
||||
import datetime
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Action:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def _send_notification(self, emitter: Callable, type: str, content: str):
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": type, "content": content}}
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
):
|
||||
print(f"action:{__name__}")
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
if __event_emitter__:
|
||||
last_assistant_message = body["messages"][-1]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "正在保存到文件...", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
message_content = last_assistant_message["content"]
|
||||
tables = self.extract_tables_from_message(message_content)
|
||||
|
||||
if not tables:
|
||||
raise HTTPException(status_code=400, detail="未找到任何表格。")
|
||||
|
||||
# 获取动态文件名和sheet名称
|
||||
workbook_name, sheet_names = self.generate_names_from_content(
|
||||
message_content, tables
|
||||
)
|
||||
|
||||
# 使用优化后的文件名生成逻辑
|
||||
current_datetime = datetime.datetime.now()
|
||||
formatted_date = current_datetime.strftime("%Y%m%d")
|
||||
|
||||
# 如果没找到标题则使用 user_yyyymmdd 格式
|
||||
if not workbook_name:
|
||||
workbook_name = f"{user_name}_{formatted_date}"
|
||||
|
||||
filename = f"{workbook_name}.xlsx"
|
||||
excel_file_path = os.path.join(
|
||||
"app", "backend", "data", "temp", filename
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(excel_file_path), exist_ok=True)
|
||||
|
||||
# 保存表格到Excel(使用符合中国规范的格式化功能)
|
||||
self.save_tables_to_excel_enhanced(tables, excel_file_path, sheet_names)
|
||||
|
||||
# 触发文件下载
|
||||
if __event_call__:
|
||||
with open(excel_file_path, "rb") as file:
|
||||
file_content = file.read()
|
||||
base64_blob = base64.b64encode(file_content).decode("utf-8")
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
try {{
|
||||
const base64Data = "{base64_blob}";
|
||||
const binaryData = atob(base64Data);
|
||||
const arrayBuffer = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {{
|
||||
arrayBuffer[i] = binaryData.charCodeAt(i);
|
||||
}}
|
||||
const blob = new Blob([arrayBuffer], {{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }});
|
||||
const filename = "{filename}";
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}} catch (error) {{
|
||||
console.error('触发下载时出错:', error);
|
||||
}}
|
||||
"""
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "输出已保存", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
if os.path.exists(excel_file_path):
|
||||
os.remove(excel_file_path)
|
||||
|
||||
return {"message": "下载事件已触发"}
|
||||
|
||||
except HTTPException as e:
|
||||
print(f"Error processing tables: {str(e.detail)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {e.detail}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
print(f"Error processing tables: {str(e)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"保存文件时出错: {str(e)}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可以导出的表格!"
|
||||
)
|
||||
|
||||
def extract_tables_from_message(self, message: str) -> List[Dict]:
|
||||
"""
|
||||
从消息文本中提取Markdown表格及位置信息
|
||||
返回结构: [{
|
||||
"data": 表格数据,
|
||||
"start_line": 起始行号,
|
||||
"end_line": 结束行号
|
||||
}]
|
||||
"""
|
||||
table_row_pattern = r"^\s*\|.*\|.*\s*$"
|
||||
rows = message.split("\n")
|
||||
tables = []
|
||||
current_table = []
|
||||
start_line = None
|
||||
current_line = 0
|
||||
|
||||
for row in rows:
|
||||
current_line += 1
|
||||
if re.search(table_row_pattern, row):
|
||||
if start_line is None:
|
||||
start_line = current_line # 记录表格起始行
|
||||
|
||||
# 处理表格行
|
||||
cells = [cell.strip() for cell in row.strip().strip("|").split("|")]
|
||||
|
||||
# 跳过分隔行
|
||||
is_separator_row = all(re.fullmatch(r"[:\-]+", cell) for cell in cells)
|
||||
if not is_separator_row:
|
||||
current_table.append(cells)
|
||||
elif current_table:
|
||||
# 表格结束
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line - 1,
|
||||
}
|
||||
)
|
||||
current_table = []
|
||||
start_line = None
|
||||
|
||||
# 处理最后一个表格
|
||||
if current_table:
|
||||
tables.append(
|
||||
{
|
||||
"data": current_table,
|
||||
"start_line": start_line,
|
||||
"end_line": current_line,
|
||||
}
|
||||
)
|
||||
|
||||
return tables
|
||||
|
||||
def generate_names_from_content(self, content: str, tables: List[Dict]) -> tuple:
|
||||
"""
|
||||
根据内容生成工作簿名称和sheet名称
|
||||
- 忽略非空段落,只使用 markdown 标题 (h1-h6)。
|
||||
- 单表格: 使用最近的标题作为工作簿和工作表名。
|
||||
- 多表格: 使用文档第一个标题作为工作簿名,各表格最近的标题作为工作表名。
|
||||
- 默认命名:
|
||||
- 工作簿: 在主流程中处理 (user_yyyymmdd.xlsx)。
|
||||
- 工作表: 表1, 表2, ...
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
workbook_name = ""
|
||||
sheet_names = []
|
||||
all_headers = []
|
||||
|
||||
# 1. 查找文档中所有 h1-h6 标题及其位置
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^#{1,6}\s+", line):
|
||||
all_headers.append(
|
||||
{"text": re.sub(r"^#{1,6}\s+", "", line).strip(), "line_num": i}
|
||||
)
|
||||
|
||||
# 2. 为每个表格生成 sheet 名称
|
||||
for i, table in enumerate(tables):
|
||||
table_start_line = table["start_line"] - 1 # 转换为 0-based 索引
|
||||
closest_header_text = None
|
||||
|
||||
# 查找当前表格上方最近的标题
|
||||
candidate_headers = [
|
||||
h for h in all_headers if h["line_num"] < table_start_line
|
||||
]
|
||||
if candidate_headers:
|
||||
# 找到候选标题中行号最大的,即为最接近的
|
||||
closest_header = max(candidate_headers, key=lambda x: x["line_num"])
|
||||
closest_header_text = closest_header["text"]
|
||||
|
||||
if closest_header_text:
|
||||
# 清理并添加找到的标题
|
||||
sheet_names.append(self.clean_sheet_name(closest_header_text))
|
||||
else:
|
||||
# 如果找不到标题,使用默认名称 "表{i+1}"
|
||||
sheet_names.append(f"表{i+1}")
|
||||
|
||||
# 3. 根据表格数量确定工作簿名称
|
||||
if len(tables) == 1:
|
||||
# 单个表格: 使用其工作表名作为工作簿名 (前提是该名称不是默认的 "表1")
|
||||
if sheet_names[0] != "表1":
|
||||
workbook_name = sheet_names[0]
|
||||
elif len(tables) > 1:
|
||||
# 多个表格: 使用文档中的第一个标题作为工作簿名
|
||||
if all_headers:
|
||||
# 找到所有标题中行号最小的,即为第一个标题
|
||||
first_header = min(all_headers, key=lambda x: x["line_num"])
|
||||
workbook_name = first_header["text"]
|
||||
|
||||
# 4. 清理工作簿名称 (如果为空,主流程会使用默认名称)
|
||||
workbook_name = self.clean_filename(workbook_name) if workbook_name else ""
|
||||
|
||||
return workbook_name, sheet_names
|
||||
|
||||
def clean_filename(self, name: str) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()
|
||||
|
||||
def clean_sheet_name(self, name: str) -> str:
|
||||
"""清理sheet名称(限制31字符,去除非法字符)"""
|
||||
name = re.sub(r"[\\/*?[\]:]", "", name).strip()
|
||||
return name[:31] if len(name) > 31 else name
|
||||
|
||||
# ======================== 符合中国规范的格式化功能 ========================
|
||||
|
||||
def calculate_text_width(self, text: str) -> float:
|
||||
"""
|
||||
计算文本显示宽度,考虑中英文字符差异
|
||||
中文字符按2个单位计算,英文字符按1个单位计算
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
width = 0
|
||||
for char in str(text):
|
||||
# 判断是否为中文字符(包括中文标点)
|
||||
if "\u4e00" <= char <= "\u9fff" or "\u3000" <= char <= "\u303f":
|
||||
width += 2 # 中文字符占2个单位宽度
|
||||
else:
|
||||
width += 1 # 英文字符占1个单位宽度
|
||||
|
||||
return width
|
||||
|
||||
def calculate_text_height(self, text: str, max_width: int = 50) -> int:
|
||||
"""
|
||||
计算文本显示所需的行数
|
||||
根据换行符和文本长度计算
|
||||
"""
|
||||
if not text:
|
||||
return 1
|
||||
|
||||
text = str(text)
|
||||
# 计算换行符导致的行数
|
||||
explicit_lines = text.count("\n") + 1
|
||||
|
||||
# 计算因文本长度超出而需要的额外行数
|
||||
text_width = self.calculate_text_width(text.replace("\n", ""))
|
||||
wrapped_lines = max(
|
||||
1, int(text_width / max_width) + (1 if text_width % max_width > 0 else 0)
|
||||
)
|
||||
|
||||
return max(explicit_lines, wrapped_lines)
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def determine_content_type(self, header: str, values: list) -> str:
|
||||
"""
|
||||
根据表头和内容智能判断数据类型,符合中国官方表格规范
|
||||
返回: 'number', 'date', 'sequence', 'text'
|
||||
"""
|
||||
header_lower = str(header).lower().strip()
|
||||
|
||||
# 检查表头关键词
|
||||
number_keywords = [
|
||||
"数量",
|
||||
"金额",
|
||||
"价格",
|
||||
"费用",
|
||||
"成本",
|
||||
"收入",
|
||||
"支出",
|
||||
"总计",
|
||||
"小计",
|
||||
"百分比",
|
||||
"%",
|
||||
"比例",
|
||||
"率",
|
||||
"数值",
|
||||
"分数",
|
||||
"成绩",
|
||||
"得分",
|
||||
]
|
||||
date_keywords = ["日期", "时间", "年份", "月份", "时刻", "date", "time"]
|
||||
sequence_keywords = [
|
||||
"序号",
|
||||
"编号",
|
||||
"号码",
|
||||
"排序",
|
||||
"次序",
|
||||
"顺序",
|
||||
"id",
|
||||
"编码",
|
||||
]
|
||||
|
||||
# 检查表头
|
||||
for keyword in number_keywords:
|
||||
if keyword in header_lower:
|
||||
return "number"
|
||||
|
||||
for keyword in date_keywords:
|
||||
if keyword in header_lower:
|
||||
return "date"
|
||||
|
||||
for keyword in sequence_keywords:
|
||||
if keyword in header_lower:
|
||||
return "sequence"
|
||||
|
||||
# 检查数据内容
|
||||
if not values:
|
||||
return "text"
|
||||
|
||||
sample_values = [
|
||||
str(v).strip() for v in values[:10] if str(v).strip()
|
||||
] # 取前10个非空值作为样本
|
||||
if not sample_values:
|
||||
return "text"
|
||||
|
||||
numeric_count = 0
|
||||
date_count = 0
|
||||
sequence_count = 0
|
||||
|
||||
for value in sample_values:
|
||||
# 检查是否为数字
|
||||
try:
|
||||
float(
|
||||
value.replace(",", "")
|
||||
.replace(",", "")
|
||||
.replace("%", "")
|
||||
.replace("%", "")
|
||||
)
|
||||
numeric_count += 1
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 检查是否为日期格式
|
||||
date_patterns = [
|
||||
r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?",
|
||||
r"\d{1,2}[-/]\d{1,2}[-/]\d{4}",
|
||||
r"\d{4}\d{2}\d{2}",
|
||||
]
|
||||
for pattern in date_patterns:
|
||||
if re.match(pattern, value):
|
||||
date_count += 1
|
||||
break
|
||||
|
||||
# 检查是否为序号格式
|
||||
if (
|
||||
re.match(r"^\d+$", value) and len(value) <= 4
|
||||
): # 纯数字且不超过4位,可能是序号
|
||||
sequence_count += 1
|
||||
|
||||
total_count = len(sample_values)
|
||||
|
||||
# 根据比例判断类型
|
||||
if numeric_count / total_count >= 0.7:
|
||||
return "number"
|
||||
elif date_count / total_count >= 0.7:
|
||||
return "date"
|
||||
elif sequence_count / total_count >= 0.8 and sequence_count > 2:
|
||||
return "sequence"
|
||||
else:
|
||||
return "text"
|
||||
|
||||
def get_column_letter(self, col_index: int) -> str:
|
||||
"""
|
||||
将列索引转换为Excel列字母 (A, B, C, ..., AA, AB, ...)
|
||||
"""
|
||||
result = ""
|
||||
while col_index >= 0:
|
||||
result = chr(65 + col_index % 26) + result
|
||||
col_index = col_index // 26 - 1
|
||||
return result
|
||||
|
||||
def save_tables_to_excel_enhanced(
|
||||
self, tables: List[Dict], file_path: str, sheet_names: List[str]
|
||||
):
|
||||
"""
|
||||
符合中国官方表格规范的Excel保存功能
|
||||
"""
|
||||
try:
|
||||
with pd.ExcelWriter(file_path, engine="xlsxwriter") as writer:
|
||||
workbook = writer.book
|
||||
|
||||
# 定义表头样式 - 居中对齐(符合中国规范)
|
||||
header_format = workbook.add_format(
|
||||
{
|
||||
"bold": True,
|
||||
"font_size": 12,
|
||||
"font_color": "white",
|
||||
"bg_color": "#00abbd",
|
||||
"border": 1,
|
||||
"align": "center", # 表头居中
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 文本单元格样式 - 左对齐
|
||||
text_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "left", # 文本左对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 数值单元格样式 - 右对齐
|
||||
number_format = workbook.add_format(
|
||||
{"border": 1, "align": "right", "valign": "vcenter"} # 数值右对齐
|
||||
)
|
||||
|
||||
# 整数格式 - 右对齐
|
||||
integer_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"border": 1,
|
||||
"align": "right", # 整数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 小数格式 - 右对齐
|
||||
decimal_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"border": 1,
|
||||
"align": "right", # 小数右对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 日期格式 - 居中对齐
|
||||
date_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 日期居中对齐
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 序号格式 - 居中对齐
|
||||
sequence_format = workbook.add_format(
|
||||
{
|
||||
"border": 1,
|
||||
"align": "center", # 序号居中对齐
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
try:
|
||||
table_data = table["data"]
|
||||
if not table_data or len(table_data) < 1:
|
||||
print(f"Skipping empty table at index {i}")
|
||||
continue
|
||||
|
||||
print(f"Processing table {i+1} with {len(table_data)} rows")
|
||||
|
||||
# 获取sheet名称
|
||||
sheet_name = (
|
||||
sheet_names[i] if i < len(sheet_names) else f"表{i+1}"
|
||||
)
|
||||
|
||||
# 创建DataFrame
|
||||
headers = [
|
||||
str(cell).strip()
|
||||
for cell in table_data[0]
|
||||
if str(cell).strip()
|
||||
]
|
||||
if not headers:
|
||||
print(f"Warning: No valid headers found for table {i+1}")
|
||||
headers = [f"列{j+1}" for j in range(len(table_data[0]))]
|
||||
|
||||
data_rows = []
|
||||
if len(table_data) > 1:
|
||||
max_cols = len(headers)
|
||||
for row in table_data[1:]:
|
||||
processed_row = []
|
||||
for j in range(max_cols):
|
||||
if j < len(row):
|
||||
processed_row.append(str(row[j]))
|
||||
else:
|
||||
processed_row.append("")
|
||||
data_rows.append(processed_row)
|
||||
df = pd.DataFrame(data_rows, columns=headers)
|
||||
else:
|
||||
df = pd.DataFrame(columns=headers)
|
||||
|
||||
print(f"DataFrame created with columns: {list(df.columns)}")
|
||||
|
||||
# 修复pandas FutureWarning - 使用try-except替代errors='ignore'
|
||||
for col in df.columns:
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 先写入数据(不包含表头)
|
||||
df.to_excel(
|
||||
writer,
|
||||
sheet_name=sheet_name,
|
||||
index=False,
|
||||
header=False,
|
||||
startrow=1,
|
||||
)
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# 应用符合中国规范的格式化
|
||||
self.apply_chinese_standard_formatting(
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing table {i+1}: {str(e)}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving Excel file: {str(e)}")
|
||||
raise
|
||||
|
||||
def apply_chinese_standard_formatting(
|
||||
self,
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
):
|
||||
"""
|
||||
应用符合中国官方表格规范的格式化
|
||||
- 表头: 居中对齐
|
||||
- 数值: 右对齐
|
||||
- 文本: 左对齐
|
||||
- 日期: 居中对齐
|
||||
- 序号: 居中对齐
|
||||
"""
|
||||
try:
|
||||
# 1. 写入表头(居中对齐)
|
||||
print(f"Writing headers with Chinese standard alignment: {headers}")
|
||||
for col_idx, header in enumerate(headers):
|
||||
if header and str(header).strip():
|
||||
worksheet.write(0, col_idx, str(header).strip(), header_format)
|
||||
else:
|
||||
default_header = f"列{col_idx+1}"
|
||||
worksheet.write(0, col_idx, default_header, header_format)
|
||||
|
||||
# 2. 分析每列的数据类型并应用相应格式
|
||||
column_types = {}
|
||||
for col_idx, column in enumerate(headers):
|
||||
if col_idx < len(df.columns):
|
||||
column_values = df.iloc[:, col_idx].tolist()
|
||||
column_types[col_idx] = self.determine_content_type(
|
||||
column, column_values
|
||||
)
|
||||
print(
|
||||
f"Column '{column}' determined as type: {column_types[col_idx]}"
|
||||
)
|
||||
else:
|
||||
column_types[col_idx] = "text"
|
||||
|
||||
# 3. 写入并格式化数据(根据类型使用不同对齐方式)
|
||||
for row_idx, row in df.iterrows():
|
||||
for col_idx, value in enumerate(row):
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
|
||||
# 根据内容类型选择格式
|
||||
if content_type == "number":
|
||||
# 数值类型 - 右对齐
|
||||
if pd.api.types.is_numeric_dtype(df.iloc[:, col_idx]):
|
||||
if pd.api.types.is_integer_dtype(df.iloc[:, col_idx]):
|
||||
current_format = integer_format
|
||||
else:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
if numeric_value.is_integer():
|
||||
current_format = integer_format
|
||||
value = int(numeric_value)
|
||||
else:
|
||||
current_format = decimal_format
|
||||
except (ValueError, TypeError):
|
||||
current_format = decimal_format
|
||||
else:
|
||||
current_format = number_format
|
||||
|
||||
elif content_type == "date":
|
||||
# 日期类型 - 居中对齐
|
||||
current_format = date_format
|
||||
|
||||
elif content_type == "sequence":
|
||||
# 序号类型 - 居中对齐
|
||||
current_format = sequence_format
|
||||
|
||||
else:
|
||||
# 文本类型 - 左对齐
|
||||
current_format = text_format
|
||||
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
|
||||
# 4. 自动调整列宽
|
||||
for col_idx, column in enumerate(headers):
|
||||
col_letter = self.get_column_letter(col_idx)
|
||||
|
||||
# 计算表头宽度
|
||||
header_width = self.calculate_text_width(str(column))
|
||||
|
||||
# 计算数据列的最大宽度
|
||||
max_data_width = 0
|
||||
if not df.empty and col_idx < len(df.columns):
|
||||
for value in df.iloc[:, col_idx]:
|
||||
value_width = self.calculate_text_width(str(value))
|
||||
max_data_width = max(max_data_width, value_width)
|
||||
|
||||
# 基础宽度:取表头和数据的最大宽度
|
||||
base_width = max(header_width, max_data_width)
|
||||
|
||||
# 根据内容类型调整宽度
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
if content_type == "sequence":
|
||||
# 序号列通常比较窄
|
||||
optimal_width = max(8, min(15, base_width + 2))
|
||||
elif content_type == "number":
|
||||
# 数值列需要额外空间显示数字
|
||||
optimal_width = max(12, min(25, base_width + 3))
|
||||
elif content_type == "date":
|
||||
# 日期列需要固定宽度
|
||||
optimal_width = max(15, min(20, base_width + 2))
|
||||
else:
|
||||
# 文本列根据内容调整
|
||||
if base_width <= 10:
|
||||
optimal_width = base_width + 3
|
||||
elif base_width <= 20:
|
||||
optimal_width = base_width + 4
|
||||
else:
|
||||
optimal_width = base_width + 5
|
||||
optimal_width = max(10, min(60, optimal_width))
|
||||
|
||||
worksheet.set_column(f"{col_letter}:{col_letter}", optimal_width)
|
||||
|
||||
# 5. 自动调整行高
|
||||
# 设置表头行高为35点
|
||||
worksheet.set_row(0, 35)
|
||||
|
||||
# 设置数据行行高
|
||||
for row_idx, row in df.iterrows():
|
||||
max_row_height = 20 # 中国表格规范建议的最小行高
|
||||
|
||||
for col_idx, value in enumerate(row):
|
||||
if col_idx < len(headers):
|
||||
col_width = min(
|
||||
60,
|
||||
max(
|
||||
10, self.calculate_text_width(str(headers[col_idx])) + 5
|
||||
),
|
||||
)
|
||||
else:
|
||||
col_width = 15
|
||||
|
||||
cell_lines = self.calculate_text_height(str(value), col_width)
|
||||
cell_height = cell_lines * 20 # 每行20点高度,符合中国规范
|
||||
|
||||
max_row_height = max(max_row_height, cell_height)
|
||||
|
||||
final_height = min(120, max_row_height)
|
||||
worksheet.set_row(row_idx + 1, final_height)
|
||||
|
||||
print(f"Successfully applied Chinese standard formatting")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to apply Chinese standard formatting: {str(e)}")
|
||||
# 降级到基础格式化
|
||||
self.apply_basic_formatting_fallback(worksheet, df)
|
||||
|
||||
def apply_basic_formatting_fallback(self, worksheet, df):
|
||||
"""
|
||||
基础格式化降级方案
|
||||
"""
|
||||
try:
|
||||
# 基础列宽调整
|
||||
for i, column in enumerate(df.columns):
|
||||
column_width = (
|
||||
max(
|
||||
len(str(column)),
|
||||
(df[column].astype(str).map(len).max() if not df.empty else 0),
|
||||
)
|
||||
+ 2
|
||||
)
|
||||
|
||||
col_letter = self.get_column_letter(i)
|
||||
worksheet.set_column(
|
||||
f"{col_letter}:{col_letter}", min(60, max(10, column_width))
|
||||
)
|
||||
|
||||
print("Applied basic formatting fallback")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Even basic formatting failed: {str(e)}")
|
||||
15
plugins/actions/knowledge-card/README.md
Normal file
15
plugins/actions/knowledge-card/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Flash Card
|
||||
|
||||
Quickly generates beautiful flashcards from text, extracting key points and categories for efficient learning.
|
||||
|
||||
## Features
|
||||
|
||||
- **Instant Generation**: Turn any text into a structured flashcard.
|
||||
- **Key Point Extraction**: Automatically identifies core concepts.
|
||||
- **Visual Design**: Generates a visually appealing HTML card.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. Send text to the chat.
|
||||
3. The plugin will analyze the text and generate a flashcard.
|
||||
15
plugins/actions/knowledge-card/README_CN.md
Normal file
15
plugins/actions/knowledge-card/README_CN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 闪记卡 (Flash Card)
|
||||
|
||||
快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类,助力高效学习。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **即时生成**:将任何文本转化为结构化的记忆卡片。
|
||||
- **要点提取**:自动识别核心概念。
|
||||
- **视觉设计**:生成视觉精美的 HTML 卡片。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 发送文本到聊天框。
|
||||
3. 插件将分析文本并生成一张闪记卡。
|
||||
554
plugins/actions/knowledge-card/knowledge_card.py
Normal file
554
plugins/actions/knowledge-card/knowledge_card.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
title: 闪记卡 (Flash Card)
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.2.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjRkZENzAwIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRkZBNzAwIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEzIDJMMyA3djEzbDEwIDV2LTZ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTEzIDJ2Nmw4LTN2MTNsLTggM3YtNnoiIGZpbGw9IiM2NjdlZWEiLz48cGF0aCBkPSJNMTMgMnY2bTAgNXYxMCIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLW9wYWNpdHk9IjAuMyIvPjwvc3ZnPg==
|
||||
description: 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
model_id: str = Field(
|
||||
default="",
|
||||
description="用于生成卡片内容的模型 ID。如果为空,则使用当前模型。",
|
||||
)
|
||||
min_text_length: int = Field(
|
||||
default=50, description="生成闪记卡所需的最小文本长度(字符数)。"
|
||||
)
|
||||
max_text_length: int = Field(
|
||||
default=2000,
|
||||
description="建议的最大文本长度。超过此长度建议使用深度分析工具。",
|
||||
)
|
||||
language: str = Field(
|
||||
default="zh", description="卡片内容的目标语言 (例如 'zh', 'en')。"
|
||||
)
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示状态更新。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
) -> Optional[dict]:
|
||||
print(f"action:{__name__} triggered")
|
||||
|
||||
if not __event_emitter__:
|
||||
return body
|
||||
|
||||
# Get the last user message
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
# Usually the action is triggered on the last message
|
||||
target_message = messages[-1]["content"]
|
||||
|
||||
# Check text length
|
||||
text_length = len(target_message)
|
||||
if text_length < self.valves.min_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "warning",
|
||||
"content": f"文本过短({text_length}字符),建议至少{self.valves.min_text_length}字符。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
if text_length > self.valves.max_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本较长({text_length}字符),建议使用'墨海拾贝'进行深度分析。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Notify user that we are generating the card
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "⚡ 正在生成闪记卡...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Extract information using LLM
|
||||
user_id = __user__.get("id") if __user__ else "default"
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
model = self.valves.model_id if self.valves.model_id else body.get("model")
|
||||
|
||||
system_prompt = f"""
|
||||
你是一个闪记卡生成专家,专注于创建适合学习和记忆的知识卡片。你的任务是将文本提炼成简洁、易记的学习卡片。
|
||||
|
||||
请提取以下字段,并以 JSON 格式返回:
|
||||
1. "title": 创建一个简短、精准的标题(6-12 字),突出核心概念
|
||||
2. "summary": 用一句话总结核心要义(20-40 字),要通俗易懂、便于记忆
|
||||
3. "key_points": 列出 3-5 个关键记忆点(每个 10-20 字)
|
||||
- 每个要点应该是独立的知识点
|
||||
- 使用简洁、口语化的表达
|
||||
- 避免冗长的句子
|
||||
4. "tags": 列出 2-4 个分类标签(每个 2-5 字)
|
||||
5. "category": 选择一个主分类(如:概念、技能、事实、方法等)
|
||||
|
||||
目标语言: {self.valves.language}
|
||||
|
||||
重要原则:
|
||||
- **极简主义**: 每个要点都要精炼到极致
|
||||
- **记忆优先**: 内容要便于记忆和回忆
|
||||
- **核心聚焦**: 只提取最核心的知识点
|
||||
- **口语化**: 使用通俗易懂的语言
|
||||
- 只返回 JSON 对象,不要包含 markdown 格式
|
||||
"""
|
||||
|
||||
prompt = f"请将以下文本提炼成一张学习记忆卡片:\n\n{target_message}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
# simple cleanup in case of markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
card_data = json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON: {e}, content: {content}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": "生成卡片数据失败,请重试。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
# 2. Generate HTML
|
||||
html_card = self.generate_html_card(card_data)
|
||||
|
||||
# 3. Append to message
|
||||
# We append it to the user message so it shows up as part of the interaction
|
||||
# Or we can append it to the assistant response if we were a Pipe, but this is an Action.
|
||||
# Actions usually modify the input or trigger a side effect.
|
||||
# To show the card, we can append it to the message content.
|
||||
|
||||
html_embed_tag = f"```html\n{html_card}\n```"
|
||||
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": "⚡ 闪记卡生成成功!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating knowledge card: {e}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"生成知识卡片时出错: {str(e)}",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
def generate_html_card(self, data):
|
||||
# Enhanced CSS with premium styling
|
||||
style = """
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.knowledge-card-container {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 25px 50px -12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.knowledge-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #4facfe);
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: -1;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.knowledge-card:hover::before {
|
||||
opacity: 0.7;
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.75em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 28px;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 1.05em;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
border-left: 5px solid #764ba2;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
font-size: 4em;
|
||||
color: rgba(118, 75, 162, 0.1);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-section-title {
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: #764ba2;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px 12px 44px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
transform: translateX(5px);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.1);
|
||||
}
|
||||
|
||||
.card-points li::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
cursor: default;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-inner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: linear-gradient(to bottom, #1e1e1e 0%, #252525 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
color: rgba(118, 75, 162, 0.2);
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
color: #d1d5db;
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: linear-gradient(to right, #252525 0%, #2d2d2d 100%);
|
||||
border-top-color: #404040;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.knowledge-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
# Enhanced HTML structure
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{style}
|
||||
</head>
|
||||
<body>
|
||||
<div class="knowledge-card-container">
|
||||
<div class="knowledge-card">
|
||||
<div class="card-inner">
|
||||
<div class="card-header">
|
||||
<div class="card-category">{data.get('category', '通用知识')}</div>
|
||||
<h2 class="card-title">{data.get('title', '知识卡片')}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-summary">
|
||||
{data.get('summary', '')}
|
||||
</div>
|
||||
<div class="card-section-title">核心要点</div>
|
||||
<ul class="card-points">
|
||||
{''.join([f'<li>{point}</li>' for point in data.get('key_points', [])])}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-tag-label">标签</span>
|
||||
{''.join([f'<span class="card-tag">#{tag}</span>' for tag in data.get('tags', [])])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return html
|
||||
554
plugins/actions/knowledge-card/knowledge_card_en.py
Normal file
554
plugins/actions/knowledge-card/knowledge_card_en.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
title: Flash Card
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.2.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjRkZENzAwIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRkZBNzAwIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEzIDJMMyA3djEzbDEwIDV2LTZ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTEzIDJ2Nmw4LTN2MTNsLTggM3YtNnoiIGZpbGw9IiM2NjdlZWEiLz48cGF0aCBkPSJNMTMgMnY2bTAgNXYxMCIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLW9wYWNpdHk9IjAuMyIvPjwvc3ZnPg==
|
||||
description: Quickly generates beautiful flashcards from text, extracting key points and categories.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
import logging
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
model_id: str = Field(
|
||||
default="",
|
||||
description="用于生成卡片内容的模型 ID。如果为空,则使用当前模型。",
|
||||
)
|
||||
min_text_length: int = Field(
|
||||
default=50, description="生成闪记卡所需的最小文本长度(字符数)。"
|
||||
)
|
||||
max_text_length: int = Field(
|
||||
default=2000,
|
||||
description="建议的最大文本长度。超过此长度建议使用深度分析工具。",
|
||||
)
|
||||
language: str = Field(
|
||||
default="zh", description="卡片内容的目标语言 (例如 'zh', 'en')。"
|
||||
)
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示状态更新。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
) -> Optional[dict]:
|
||||
print(f"action:{__name__} triggered")
|
||||
|
||||
if not __event_emitter__:
|
||||
return body
|
||||
|
||||
# Get the last user message
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
# Usually the action is triggered on the last message
|
||||
target_message = messages[-1]["content"]
|
||||
|
||||
# Check text length
|
||||
text_length = len(target_message)
|
||||
if text_length < self.valves.min_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "warning",
|
||||
"content": f"文本过短({text_length}字符),建议至少{self.valves.min_text_length}字符。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
if text_length > self.valves.max_text_length:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本较长({text_length}字符),建议使用'墨海拾贝'进行深度分析。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Notify user that we are generating the card
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "⚡ 正在生成闪记卡...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Extract information using LLM
|
||||
user_id = __user__.get("id") if __user__ else "default"
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
|
||||
model = self.valves.model_id if self.valves.model_id else body.get("model")
|
||||
|
||||
system_prompt = f"""
|
||||
你是一个闪记卡生成专家,专注于创建适合学习和记忆的知识卡片。你的任务是将文本提炼成简洁、易记的学习卡片。
|
||||
|
||||
请提取以下字段,并以 JSON 格式返回:
|
||||
1. "title": 创建一个简短、精准的标题(6-12 字),突出核心概念
|
||||
2. "summary": 用一句话总结核心要义(20-40 字),要通俗易懂、便于记忆
|
||||
3. "key_points": 列出 3-5 个关键记忆点(每个 10-20 字)
|
||||
- 每个要点应该是独立的知识点
|
||||
- 使用简洁、口语化的表达
|
||||
- 避免冗长的句子
|
||||
4. "tags": 列出 2-4 个分类标签(每个 2-5 字)
|
||||
5. "category": 选择一个主分类(如:概念、技能、事实、方法等)
|
||||
|
||||
目标语言: {self.valves.language}
|
||||
|
||||
重要原则:
|
||||
- **极简主义**: 每个要点都要精炼到极致
|
||||
- **记忆优先**: 内容要便于记忆和回忆
|
||||
- **核心聚焦**: 只提取最核心的知识点
|
||||
- **口语化**: 使用通俗易懂的语言
|
||||
- 只返回 JSON 对象,不要包含 markdown 格式
|
||||
"""
|
||||
|
||||
prompt = f"请将以下文本提炼成一张学习记忆卡片:\n\n{target_message}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
# simple cleanup in case of markdown code blocks
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
card_data = json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse JSON: {e}, content: {content}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": "生成卡片数据失败,请重试。",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
# 2. Generate HTML
|
||||
html_card = self.generate_html_card(card_data)
|
||||
|
||||
# 3. Append to message
|
||||
# We append it to the user message so it shows up as part of the interaction
|
||||
# Or we can append it to the assistant response if we were a Pipe, but this is an Action.
|
||||
# Actions usually modify the input or trigger a side effect.
|
||||
# To show the card, we can append it to the message content.
|
||||
|
||||
html_embed_tag = f"```html\n{html_card}\n```"
|
||||
body["messages"][-1]["content"] += f"\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": "⚡ 闪记卡生成成功!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating knowledge card: {e}")
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"生成知识卡片时出错: {str(e)}",
|
||||
},
|
||||
}
|
||||
)
|
||||
return body
|
||||
|
||||
def generate_html_card(self, data):
|
||||
# Enhanced CSS with premium styling
|
||||
style = """
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.knowledge-card-container {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 30px 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 25px 50px -12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.knowledge-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #4facfe);
|
||||
border-radius: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: -1;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.knowledge-card:hover::before {
|
||||
opacity: 0.7;
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 0.7em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.75em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 28px;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 1.05em;
|
||||
color: #374151;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
border-left: 5px solid #764ba2;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
font-size: 4em;
|
||||
color: rgba(118, 75, 162, 0.1);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-section-title {
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
color: #764ba2;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px 12px 44px;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
transform: translateX(5px);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: #764ba2;
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.1);
|
||||
}
|
||||
|
||||
.card-points li::before {
|
||||
content: '✓';
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 20px 28px;
|
||||
background: linear-gradient(to right, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
cursor: default;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-inner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: linear-gradient(to bottom, #1e1e1e 0%, #252525 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-summary::before {
|
||||
color: rgba(118, 75, 162, 0.2);
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
color: #d1d5db;
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.card-points li:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: linear-gradient(to right, #252525 0%, #2d2d2d 100%);
|
||||
border-top-color: #404040;
|
||||
}
|
||||
|
||||
.card-tag-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.knowledge-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
# Enhanced HTML structure
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{style}
|
||||
</head>
|
||||
<body>
|
||||
<div class="knowledge-card-container">
|
||||
<div class="knowledge-card">
|
||||
<div class="card-inner">
|
||||
<div class="card-header">
|
||||
<div class="card-category">{data.get('category', '通用知识')}</div>
|
||||
<h2 class="card-title">{data.get('title', '知识卡片')}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-summary">
|
||||
{data.get('summary', '')}
|
||||
</div>
|
||||
<div class="card-section-title">核心要点</div>
|
||||
<ul class="card-points">
|
||||
{''.join([f'<li>{point}</li>' for point in data.get('key_points', [])])}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-tag-label">标签</span>
|
||||
{''.join([f'<span class="card-tag">#{tag}</span>' for tag in data.get('tags', [])])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return html
|
||||
210
plugins/actions/smart-mind-map/README.md
Normal file
210
plugins/actions/smart-mind-map/README.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Smart Mind Map - Mind Mapping Generation Plugin
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.7.2 | **License:** MIT
|
||||
|
||||
> **Important**: To ensure the maintainability and usability of all plugins, each plugin should be accompanied by clear and comprehensive documentation to ensure its functionality, configuration, and usage are well explained.
|
||||
|
||||
Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes long-form text content and automatically generates interactive mind maps, helping users structure and visualize knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
- ✅ **Intelligent Text Analysis**: Automatically identifies core themes, key concepts, and hierarchical structures
|
||||
- ✅ **Interactive Visualization**: Generates beautiful interactive mind maps based on Markmap.js
|
||||
- ✅ **Multi-language Support**: Automatically adjusts output based on user language
|
||||
- ✅ **Real-time Rendering**: Renders mind maps directly in the chat interface without navigation
|
||||
- ✅ **Export Capabilities**: Supports copying SVG code and Markdown source
|
||||
- ✅ **Customizable Configuration**: Configurable LLM model, minimum text length, and other parameters
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Text Extraction**: Extracts text content from user messages (automatically filters HTML code blocks)
|
||||
2. **Intelligent Analysis**: Analyzes text structure using the configured LLM model
|
||||
3. **Markdown Generation**: Converts analysis results to Markmap-compatible Markdown format
|
||||
4. **Visual Rendering**: Renders the mind map using Markmap.js in an HTML template
|
||||
5. **Interactive Display**: Presents the mind map to users in an interactive format within the chat interface
|
||||
|
||||
---
|
||||
|
||||
## Installation and Configuration
|
||||
|
||||
### 1. Plugin Installation
|
||||
|
||||
1. Download the `思维导图.py` file to your local computer
|
||||
2. In OpenWebUI Admin Settings, find the "Plugins" section
|
||||
3. Select "Actions" type
|
||||
4. Upload the downloaded file
|
||||
5. Refresh the page, and the plugin will be available
|
||||
|
||||
### 2. Model Configuration
|
||||
|
||||
The plugin requires access to an LLM model for text analysis. Please ensure:
|
||||
|
||||
- Your OpenWebUI instance has at least one available LLM model configured
|
||||
- Recommended to use fast, economical models (e.g., `gemini-2.5-flash`) for the best experience
|
||||
- Configure the `LLM_MODEL_ID` parameter in the plugin settings
|
||||
|
||||
### 3. Plugin Activation
|
||||
|
||||
Select the "Smart Mind Map" action plugin in chat settings to enable it.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
You can adjust the following parameters in the plugin's settings (Valves):
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `show_status` | `true` | Whether to display operation status updates in the chat interface (e.g., "Analyzing..."). |
|
||||
| `LLM_MODEL_ID` | `gemini-2.5-flash` | LLM model ID for text analysis. Recommended to use fast and economical models. |
|
||||
| `MIN_TEXT_LENGTH` | `100` | Minimum text length (in characters) required for mind map analysis. Text that's too short cannot generate valid mind maps. |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
1. Enable the "Smart Mind Map" action in chat settings
|
||||
2. Input or paste long-form text content (at least 100 characters) in the conversation
|
||||
3. After sending the message, the plugin will automatically analyze and generate a mind map
|
||||
4. The mind map will be rendered directly in the chat interface
|
||||
|
||||
### Usage Example
|
||||
|
||||
**Input Text:**
|
||||
```
|
||||
Artificial Intelligence (AI) is a branch of computer science dedicated to creating systems capable of performing tasks that typically require human intelligence.
|
||||
Main application areas include:
|
||||
1. Machine Learning - Enables computers to learn from data
|
||||
2. Natural Language Processing - Understanding and generating human language
|
||||
3. Computer Vision - Recognizing and processing images
|
||||
4. Robotics - Creating intelligent systems that can interact with the physical world
|
||||
```
|
||||
|
||||
**Generated Result:**
|
||||
The plugin will generate an interactive mind map centered on "Artificial Intelligence", including major application areas and their sub-concepts.
|
||||
|
||||
### Export Features
|
||||
|
||||
Generated mind maps support two export methods:
|
||||
|
||||
1. **Copy SVG Code**: Click the "Copy SVG Code" button to copy the mind map in SVG format to the clipboard
|
||||
2. **Copy Markdown**: Click the "Copy Markdown" button to copy the raw Markdown format to the clipboard
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Frontend Rendering
|
||||
|
||||
- **Markmap.js**: Open-source mind mapping rendering engine
|
||||
- **D3.js**: Data visualization foundation library
|
||||
- **Responsive Design**: Adapts to different screen sizes
|
||||
|
||||
### Backend Processing
|
||||
|
||||
- **LLM Integration**: Calls configured models via `generate_chat_completion`
|
||||
- **Text Preprocessing**: Automatically filters HTML code blocks, extracts plain text content
|
||||
- **Format Conversion**: Converts LLM output to Markmap-compatible Markdown format
|
||||
|
||||
### Security
|
||||
|
||||
- **XSS Protection**: Automatically escapes `</script>` tags to prevent script injection
|
||||
- **Input Validation**: Checks text length to avoid invalid requests
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Plugin Won't Start
|
||||
|
||||
**Solution:**
|
||||
- Check OpenWebUI logs for error messages
|
||||
- Confirm the plugin is correctly uploaded and enabled
|
||||
- Verify OpenWebUI version supports action plugins
|
||||
|
||||
### Issue: Text Content Too Short
|
||||
|
||||
**Symptom:** Prompt shows "Text content is too short for effective analysis"
|
||||
|
||||
**Solution:**
|
||||
- Ensure input text contains at least 100 characters (default configuration)
|
||||
- Lower the `MIN_TEXT_LENGTH` parameter value in plugin settings
|
||||
- Provide more detailed, structured text content
|
||||
|
||||
### Issue: Mind Map Not Generated
|
||||
|
||||
**Solution:**
|
||||
- Check if `LLM_MODEL_ID` is configured correctly
|
||||
- Confirm the configured model is available in OpenWebUI
|
||||
- Review backend logs for LLM call failures
|
||||
- Verify user has sufficient permissions to access the configured model
|
||||
|
||||
### Issue: Mind Map Display Error
|
||||
|
||||
**Symptom:** Shows "⚠️ Mind map rendering failed"
|
||||
|
||||
**Solution:**
|
||||
- Check browser console for error messages
|
||||
- Confirm Markmap.js and D3.js libraries are loading correctly
|
||||
- Verify generated Markdown format conforms to Markmap specifications
|
||||
- Try refreshing the page to re-render
|
||||
|
||||
### Issue: Export Function Not Working
|
||||
|
||||
**Solution:**
|
||||
- Confirm browser supports Clipboard API
|
||||
- Check if browser is blocking clipboard access permissions
|
||||
- Use modern browsers (Chrome, Firefox, Edge, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Text Preparation**
|
||||
- Provide text content with clear structure and distinct hierarchies
|
||||
- Use paragraphs, lists, and other formatting to help LLM understand text structure
|
||||
- Avoid excessively lengthy or unstructured text
|
||||
|
||||
2. **Model Selection**
|
||||
- For daily use, recommend fast models like `gemini-2.5-flash`
|
||||
- For complex text analysis, use more powerful models (e.g., GPT-4)
|
||||
- Balance speed and analysis quality based on needs
|
||||
|
||||
3. **Performance Optimization**
|
||||
- Set `MIN_TEXT_LENGTH` appropriately to avoid processing text that's too short
|
||||
- For particularly long texts, consider summarizing before generating mind maps
|
||||
- Disable `show_status` in production environments to reduce interface updates
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.7.2 (Current Version)
|
||||
- Optimized text extraction logic, automatically filters HTML code blocks
|
||||
- Improved error handling and user feedback
|
||||
- Enhanced export functionality compatibility
|
||||
- Optimized UI styling and interactive experience
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This plugin is released under the MIT License.
|
||||
|
||||
## Contributing
|
||||
|
||||
Welcome to submit issue reports and improvement suggestions! Please visit the project repository: [awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
---
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Markmap Official Website](https://markmap.js.org/)
|
||||
- [OpenWebUI Documentation](https://docs.openwebui.com/)
|
||||
- [D3.js Official Website](https://d3js.org/)
|
||||
210
plugins/actions/smart-mind-map/README_CN.md
Normal file
210
plugins/actions/smart-mind-map/README_CN.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 智绘心图 - 思维导图生成插件
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.7.2 | **许可证:** MIT
|
||||
|
||||
> **重要提示**:为了确保所有插件的可维护性和易用性,每个插件都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
|
||||
|
||||
智绘心图是一个强大的 OpenWebUI 动作插件,能够智能分析长篇文本内容,自动生成交互式思维导图,帮助用户结构化和可视化知识。
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
- ✅ **智能文本分析**: 自动识别文本的核心主题、关键概念和层次结构
|
||||
- ✅ **交互式可视化**: 基于 Markmap.js 生成美观的交互式思维导图
|
||||
- ✅ **多语言支持**: 根据用户语言自动调整输出
|
||||
- ✅ **实时渲染**: 在聊天界面中直接渲染思维导图,无需跳转
|
||||
- ✅ **导出功能**: 支持复制 SVG 代码和 Markdown 源码
|
||||
- ✅ **自定义配置**: 可配置 LLM 模型、最小文本长度等参数
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **文本提取**: 从用户消息中提取文本内容(自动过滤 HTML 代码块)
|
||||
2. **智能分析**: 使用配置的 LLM 模型分析文本结构
|
||||
3. **Markdown 生成**: 将分析结果转换为 Markmap 兼容的 Markdown 格式
|
||||
4. **可视化渲染**: 在 HTML 模板中使用 Markmap.js 渲染思维导图
|
||||
5. **交互展示**: 在聊天界面中以可交互的形式展示给用户
|
||||
|
||||
---
|
||||
|
||||
## 安装与配置
|
||||
|
||||
### 1. 插件安装
|
||||
|
||||
1. 下载 `思维导图.py` 文件到本地
|
||||
2. 在 OpenWebUI 管理员设置中找到"插件"(Plugins)部分
|
||||
3. 选择"动作"(Actions)类型
|
||||
4. 上传下载的文件
|
||||
5. 刷新页面,插件即可使用
|
||||
|
||||
### 2. 模型配置
|
||||
|
||||
插件需要访问 LLM 模型来分析文本。请确保:
|
||||
|
||||
- 您的 OpenWebUI 实例中配置了至少一个可用的 LLM 模型
|
||||
- 推荐使用快速、经济的模型(如 `gemini-2.5-flash`)来获得最佳体验
|
||||
- 在插件设置中配置 `LLM_MODEL_ID` 参数
|
||||
|
||||
### 3. 插件启用
|
||||
|
||||
在聊天设置中选择"智绘心图"动作插件即可启用。
|
||||
|
||||
---
|
||||
|
||||
## 配置参数
|
||||
|
||||
您可以在插件的设置(Valves)中调整以下参数:
|
||||
|
||||
| 参数 | 默认值 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| `show_status` | `true` | 是否在聊天界面显示操作状态更新(如"正在分析...")。 |
|
||||
| `LLM_MODEL_ID` | `gemini-2.5-flash` | 用于文本分析的 LLM 模型 ID。推荐使用快速且经济的模型。 |
|
||||
| `MIN_TEXT_LENGTH` | `100` | 进行思维导图分析所需的最小文本长度(字符数)。文本过短将无法生成有效的导图。 |
|
||||
|
||||
---
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本使用
|
||||
|
||||
1. 在聊天设置中启用"智绘心图"动作
|
||||
2. 在对话中输入或粘贴长篇文本内容(至少 100 字符)
|
||||
3. 发送消息后,插件会自动分析并生成思维导图
|
||||
4. 思维导图将在聊天界面中直接渲染显示
|
||||
|
||||
### 使用示例
|
||||
|
||||
**输入文本:**
|
||||
```
|
||||
人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。
|
||||
主要应用领域包括:
|
||||
1. 机器学习 - 使计算机能够从数据中学习
|
||||
2. 自然语言处理 - 理解和生成人类语言
|
||||
3. 计算机视觉 - 识别和处理图像
|
||||
4. 机器人技术 - 创建能够与物理世界交互的智能系统
|
||||
```
|
||||
|
||||
**生成结果:**
|
||||
插件会生成一个以"人工智能"为中心主题的交互式思维导图,包含主要应用领域及其子概念。
|
||||
|
||||
### 导出功能
|
||||
|
||||
生成的思维导图支持两种导出方式:
|
||||
|
||||
1. **复制 SVG 代码**: 点击"复制 SVG 代码"按钮,可将思维导图的 SVG 格式复制到剪贴板
|
||||
2. **复制 Markdown**: 点击"复制 Markdown"按钮,可将原始 Markdown 格式复制到剪贴板
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 前端渲染
|
||||
|
||||
- **Markmap.js**: 开源的思维导图渲染引擎
|
||||
- **D3.js**: 数据可视化基础库
|
||||
- **响应式设计**: 适配不同屏幕尺寸
|
||||
|
||||
### 后端处理
|
||||
|
||||
- **LLM 集成**: 通过 `generate_chat_completion` 调用配置的模型
|
||||
- **文本预处理**: 自动过滤 HTML 代码块,提取纯文本内容
|
||||
- **格式转换**: 将 LLM 输出转换为 Markmap 兼容的 Markdown 格式
|
||||
|
||||
### 安全性
|
||||
|
||||
- **XSS 防护**: 自动转义 `</script>` 标签,防止脚本注入
|
||||
- **输入验证**: 检查文本长度,避免无效请求
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题:插件无法启动
|
||||
|
||||
**解决方案:**
|
||||
- 检查 OpenWebUI 日志,查看是否有错误信息
|
||||
- 确认插件已正确上传并启用
|
||||
- 验证 OpenWebUI 版本是否支持动作插件
|
||||
|
||||
### 问题:文本内容过短
|
||||
|
||||
**现象:** 提示"文本内容过短,无法进行有效分析"
|
||||
|
||||
**解决方案:**
|
||||
- 确保输入的文本至少包含 100 个字符(默认配置)
|
||||
- 可以在插件设置中降低 `MIN_TEXT_LENGTH` 参数值
|
||||
- 提供更详细、结构化的文本内容
|
||||
|
||||
### 问题:思维导图未生成
|
||||
|
||||
**解决方案:**
|
||||
- 检查 `LLM_MODEL_ID` 是否配置正确
|
||||
- 确认配置的模型在 OpenWebUI 中可用
|
||||
- 查看后端日志,检查是否有 LLM 调用失败的错误
|
||||
- 验证用户是否有足够的权限访问配置的模型
|
||||
|
||||
### 问题:思维导图显示错误
|
||||
|
||||
**现象:** 显示"⚠️ 思维导图渲染失败"
|
||||
|
||||
**解决方案:**
|
||||
- 检查浏览器控制台的错误信息
|
||||
- 确认 Markmap.js 和 D3.js 库是否正确加载
|
||||
- 验证生成的 Markdown 格式是否符合 Markmap 规范
|
||||
- 尝试刷新页面重新渲染
|
||||
|
||||
### 问题:导出功能不工作
|
||||
|
||||
**解决方案:**
|
||||
- 确认浏览器支持剪贴板 API
|
||||
- 检查浏览器是否阻止了剪贴板访问权限
|
||||
- 使用现代浏览器(Chrome、Firefox、Edge 等)
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **文本准备**
|
||||
- 提供结构清晰、层次分明的文本内容
|
||||
- 使用段落、列表等格式帮助 LLM 理解文本结构
|
||||
- 避免过于冗长或无结构的文本
|
||||
|
||||
2. **模型选择**
|
||||
- 对于日常使用,推荐 `gemini-2.5-flash` 等快速模型
|
||||
- 对于复杂文本分析,可以使用更强大的模型(如 GPT-4)
|
||||
- 根据需求平衡速度和分析质量
|
||||
|
||||
3. **性能优化**
|
||||
- 合理设置 `MIN_TEXT_LENGTH`,避免处理过短的文本
|
||||
- 对于特别长的文本,考虑先进行摘要再生成思维导图
|
||||
- 在生产环境中关闭 `show_status` 以减少界面更新
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.7.2 (当前版本)
|
||||
- 优化文本提取逻辑,自动过滤 HTML 代码块
|
||||
- 改进错误处理和用户反馈
|
||||
- 增强导出功能的兼容性
|
||||
- 优化 UI 样式和交互体验
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本插件采用 MIT 许可证发布。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交问题报告和改进建议!请访问项目仓库:[awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
---
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Markmap 官方网站](https://markmap.js.org/)
|
||||
- [OpenWebUI 文档](https://docs.openwebui.com/)
|
||||
- [D3.js 官方网站](https://d3js.org/)
|
||||
611
plugins/actions/smart-mind-map/smart_mind_map.py
Normal file
611
plugins/actions/smart-mind-map/smart_mind_map.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
title: Smart Mind Map
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSIxMC41IiB4Mj0iNiIgeTI9IjYiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSIxMC41IiB4Mj0iMTgiIHkyPSI2Ii8+CiAgPGNpcmNsZSBjeD0iMTkiIGN5PSI1IiByPSIxLjUiLz4KICA8bGluZSB4MT0iMTAuNSIgeTE9IjEzLjUiIHgyPSI2IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iMTkiIHI9IjEuNSIvPgogIDxsaW5lIHgxPSIxMy41IiB5MT0iMTMuNSIgeDI9IjE4IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSIxOSIgY3k9IjE5IiByPSIxLjUiLz4KPC9zdmc+
|
||||
version: 0.7.3
|
||||
description: 智能分析长文本并生成交互式思维导图,支持 SVG/Markdown 导出。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT_MINDMAP_ASSISTANT = """
|
||||
You are a professional mind map generation assistant, capable of efficiently analyzing long-form text provided by users and structuring its core themes, key concepts, branches, and sub-branches into standard Markdown list syntax for rendering by Markmap.js.
|
||||
|
||||
Please strictly follow these guidelines:
|
||||
- **Language**: All output must be in the language specified by the user.
|
||||
- **Format**: Your output must strictly be in Markdown list format, wrapped with ```markdown and ```.
|
||||
- Use `#` to define the central theme (root node).
|
||||
- Use `-` with two-space indentation to represent branches and sub-branches.
|
||||
- **Content**:
|
||||
- Identify the central theme of the text as the `#` heading.
|
||||
- Identify main concepts as first-level list items.
|
||||
- Identify supporting details or sub-concepts as nested list items.
|
||||
- Node content should be concise and clear, avoiding verbosity.
|
||||
- **Output Markdown syntax only**: Do not include any additional greetings, explanations, or guiding text.
|
||||
- **If text is too short or cannot generate a valid mind map**: Output a simple Markdown list indicating inability to generate, for example:
|
||||
```markdown
|
||||
# Unable to Generate Mind Map
|
||||
- Reason: Insufficient or unclear text content
|
||||
```
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_MINDMAP = """
|
||||
Please analyze the following long-form text and structure its core themes, key concepts, branches, and sub-branches into standard Markdown list syntax for Markmap.js rendering.
|
||||
|
||||
---
|
||||
**User Context Information:**
|
||||
User Name: {user_name}
|
||||
Current Date & Time: {current_date_time_str}
|
||||
Current Weekday: {current_weekday}
|
||||
Current Timezone: {current_timezone_str}
|
||||
User Language: {user_language}
|
||||
---
|
||||
|
||||
**Long-form Text Content:**
|
||||
{long_text_content}
|
||||
"""
|
||||
|
||||
HTML_TEMPLATE_MINDMAP = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Smart Mind Map: Mind Map Visualization</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-lib@0.17"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.17"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1e88e5;
|
||||
--secondary-color: #43a047;
|
||||
--background-color: #f4f6f8;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #263238;
|
||||
--muted-text-color: #546e7a;
|
||||
--border-color: #e0e0e0;
|
||||
--header-gradient: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||||
--shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--border-radius: 12px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #eceff1;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.user-context span { margin: 4px 10px; }
|
||||
.content-area {
|
||||
padding: 30px 40px;
|
||||
}
|
||||
.markmap-container {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
background-image: radial-gradient(var(--border-color) 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
min-height: 700px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.download-area {
|
||||
text-align: center;
|
||||
padding-top: 30px;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.download-btn {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.download-btn.secondary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.download-btn.copied {
|
||||
background-color: #2e7d32; /* A darker green for success */
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #90a4ae;
|
||||
background-color: #eceff1;
|
||||
}
|
||||
.footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error-message {
|
||||
color: #c62828;
|
||||
background-color: #ffcdd2;
|
||||
border: 1px solid #ef9a9a;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧠 Smart Mind Map</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>User:</strong> {user_name}</span>
|
||||
<span><strong>Analysis Time:</strong> {current_date_time_str}</span>
|
||||
<span><strong>Weekday:</strong> {current_weekday_zh}</span>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
||||
<div class="download-area">
|
||||
<button id="download-svg-btn-{unique_id}" class="download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span class="btn-text">Copy SVG Code</span>
|
||||
</button>
|
||||
<button id="download-md-btn-{unique_id}" class="download-btn secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span class="btn-text">Copy Markdown</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {current_year} Smart Mind Map • Rendering engine powered by <a href="https://markmap.js.org/" target="_blank">Markmap</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const renderMindmap = () => {
|
||||
const uniqueId = "{unique_id}";
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
if (!containerEl || containerEl.dataset.markmapRendered) return;
|
||||
|
||||
const sourceEl = document.getElementById('markdown-source-' + uniqueId);
|
||||
if (!sourceEl) return;
|
||||
|
||||
const markdownContent = sourceEl.textContent.trim();
|
||||
if (!markdownContent) {
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ Unable to load mind map: Missing valid content.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svgEl.style.width = '100%';
|
||||
svgEl.style.height = '700px';
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.appendChild(svgEl);
|
||||
|
||||
const { Transformer, Markmap } = window.markmap;
|
||||
const transformer = new Transformer();
|
||||
const { root } = transformer.transform(markdownContent);
|
||||
|
||||
const style = (id) => `${id} text { font-size: 16px !important; }`;
|
||||
|
||||
const options = {
|
||||
autoFit: true,
|
||||
style: style
|
||||
};
|
||||
Markmap.create(svgEl, options, root);
|
||||
|
||||
containerEl.dataset.markmapRendered = 'true';
|
||||
|
||||
attachDownloadHandlers(uniqueId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Markmap rendering error:', error);
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ Mind map rendering failed!<br>Reason: ' + error.message + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
const attachDownloadHandlers = (uniqueId) => {
|
||||
const downloadSvgBtn = document.getElementById('download-svg-btn-' + uniqueId);
|
||||
const downloadMdBtn = document.getElementById('download-md-btn-' + uniqueId);
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
|
||||
const showFeedback = (button, isSuccess) => {
|
||||
const buttonText = button.querySelector('.btn-text');
|
||||
const originalText = buttonText.textContent;
|
||||
|
||||
button.disabled = true;
|
||||
if (isSuccess) {
|
||||
buttonText.textContent = '✅ Copied!';
|
||||
button.classList.add('copied');
|
||||
} else {
|
||||
buttonText.textContent = '❌ Copy Failed';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
buttonText.textContent = originalText;
|
||||
button.disabled = false;
|
||||
button.classList.remove('copied');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const copyToClipboard = (content, button) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
showFeedback(button, true);
|
||||
}, () => {
|
||||
showFeedback(button, false);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older/insecure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = content;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showFeedback(button, true);
|
||||
} catch (err) {
|
||||
showFeedback(button, false);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
if (downloadSvgBtn) {
|
||||
downloadSvgBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const svgEl = containerEl.querySelector('svg');
|
||||
if (svgEl) {
|
||||
const svgData = new XMLSerializer().serializeToString(svgEl);
|
||||
copyToClipboard(svgData, downloadSvgBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadMdBtn) {
|
||||
downloadMdBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const markdownContent = document.getElementById('markdown-source-' + uniqueId).textContent;
|
||||
copyToClipboard(markdownContent, downloadMdBtn);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderMindmap);
|
||||
} else {
|
||||
renderMindmap();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show action status updates in the chat interface.",
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="Built-in LLM model ID for text analysis.",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=100,
|
||||
description="Minimum text length (character count) required for mind map analysis.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.weekday_map = {
|
||||
"Monday": "Monday",
|
||||
"Tuesday": "Tuesday",
|
||||
"Wednesday": "Wednesday",
|
||||
"Thursday": "Thursday",
|
||||
"Friday": "Friday",
|
||||
"Saturday": "Saturday",
|
||||
"Sunday": "Sunday",
|
||||
}
|
||||
|
||||
def _extract_markdown_syntax(self, llm_output: str) -> str:
|
||||
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
extracted_content = match.group(1).strip()
|
||||
else:
|
||||
logger.warning(
|
||||
"LLM output did not strictly follow the expected Markdown format, treating the entire output as summary."
|
||||
)
|
||||
extracted_content = llm_output.strip()
|
||||
return extracted_content.replace("</script>", "<\\/script>")
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: Smart Mind Map (v0.7.2) started")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "en-US") if __user__ else "en-US"
|
||||
)
|
||||
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
try:
|
||||
shanghai_tz = pytz.timezone("Asia/Shanghai")
|
||||
current_datetime_shanghai = datetime.now(shanghai_tz)
|
||||
current_date_time_str = current_datetime_shanghai.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
current_weekday_en = current_datetime_shanghai.strftime("%A")
|
||||
current_weekday_zh = self.weekday_map.get(current_weekday_en, "Unknown")
|
||||
current_year = current_datetime_shanghai.strftime("%Y")
|
||||
current_timezone_str = "Asia/Shanghai"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get timezone info: {e}, using default values.")
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday_zh = "Unknown"
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "Unknown"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "Smart Mind Map is starting, generating mind map for you...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
messages = body.get("messages")
|
||||
if (
|
||||
not messages
|
||||
or not isinstance(messages, list)
|
||||
or not messages[-1].get("content")
|
||||
):
|
||||
error_message = "Unable to retrieve valid user message content."
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": error_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
|
||||
}
|
||||
|
||||
parts = re.split(r"```html.*?```", messages[-1]["content"], flags=re.DOTALL)
|
||||
long_text_content = ""
|
||||
if parts:
|
||||
for part in reversed(parts):
|
||||
if part.strip():
|
||||
long_text_content = part.strip()
|
||||
break
|
||||
|
||||
if not long_text_content:
|
||||
long_text_content = messages[-1]["content"].strip()
|
||||
|
||||
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"Text content is too short ({len(long_text_content)} characters), unable to perform effective analysis. Please provide at least {self.valves.MIN_TEXT_LENGTH} characters of text."
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Smart Mind Map: Analyzing text structure in depth...",
|
||||
"done": False,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
unique_id = f"id_{int(time.time() * 1000)}"
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_MINDMAP.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday_zh,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=long_text_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_MINDMAP_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
}
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"Unable to get user object, user ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
|
||||
if (
|
||||
not llm_response
|
||||
or "choices" not in llm_response
|
||||
or not llm_response["choices"]
|
||||
):
|
||||
raise ValueError("LLM response format is incorrect or empty.")
|
||||
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
||||
|
||||
final_html_content = (
|
||||
HTML_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
||||
.replace("{user_language}", user_language)
|
||||
.replace("{user_name}", user_name)
|
||||
.replace("{current_date_time_str}", current_date_time_str)
|
||||
.replace("{current_weekday_zh}", current_weekday_zh)
|
||||
.replace("{current_year}", current_year)
|
||||
.replace("{markdown_syntax}", markdown_syntax)
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Smart Mind Map: Drawing completed!",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"Mind map has been generated, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info("Action: Smart Mind Map (v0.7.2) completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Smart Mind Map processing failed: {str(e)}"
|
||||
logger.error(f"Smart Mind Map error: {error_message}", exc_info=True)
|
||||
user_facing_error = f"Sorry, Smart Mind Map encountered an error during processing: {str(e)}.\nPlease check the Open WebUI backend logs for more details."
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{long_text_content}\n\n❌ **Error:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "Smart Mind Map: Processing failed.",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"Smart Mind Map generation failed, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
611
plugins/actions/smart-mind-map/思维导图.py
Normal file
611
plugins/actions/smart-mind-map/思维导图.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
title: 智绘心图
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIgZmlsbD0iY3VycmVudENvbG9yIi8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iOSIgeDI9IjEyIiB5Mj0iNCIvPgogIDxjaXJjbGUgY3g9IjEyIiBjeT0iMyIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEyIiB5MT0iMTUiIHgyPSIxMiIgeTI9IjIwIi8+CiAgPGNpcmNsZSBjeD0iMTIiIGN5PSIyMSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjkiIHkxPSIxMiIgeDI9IjQiIHkyPSIxMiIvPgogIDxjaXJjbGUgY3g9IjMiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjE1IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIi8+CiAgPGNpcmNsZSBjeD0iMjEiIGN5PSIxMiIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEwLjUiIHkxPSIxMC41IiB4Mj0iNiIgeTI9IjYiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iMS41Ii8+CiAgPGxpbmUgeDE9IjEzLjUiIHkxPSIxMC41IiB4Mj0iMTgiIHkyPSI2Ii8+CiAgPGNpcmNsZSBjeD0iMTkiIGN5PSI1IiByPSIxLjUiLz4KICA8bGluZSB4MT0iMTAuNSIgeTE9IjEzLjUiIHgyPSI2IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSI1IiBjeT0iMTkiIHI9IjEuNSIvPgogIDxsaW5lIHgxPSIxMy41IiB5MT0iMTMuNSIgeDI9IjE4IiB5Mj0iMTgiLz4KICA8Y2lyY2xlIGN4PSIxOSIgY3k9IjE5IiByPSIxLjUiLz4KPC9zdmc+
|
||||
version: 0.7.2
|
||||
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT_MINDMAP_ASSISTANT = """
|
||||
你是一个专业的思维导图生成助手,能够高效地分析用户提供的长篇文本,并将其核心主题、关键概念、分支和子分支结构化为标准的Markdown列表语法,以便Markmap.js进行渲染。
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**: 所有输出必须使用用户指定的语言。
|
||||
- **格式**: 你的输出必须严格为Markdown列表格式,并用```markdown 和 ``` 包裹。
|
||||
- 使用 `#` 定义中心主题(根节点)。
|
||||
- 使用 `-` 和两个空格的缩进表示分支和子分支。
|
||||
- **内容**:
|
||||
- 识别文本的中心主题作为 `#` 标题。
|
||||
- 识别主要概念作为一级列表项。
|
||||
- 识别支持性细节或子概念作为嵌套的列表项。
|
||||
- 节点内容应简洁明了,避免冗长。
|
||||
- **只输出Markdown语法**: 不要包含任何额外的寒暄、解释或引导性文字。
|
||||
- **如果文本过短或无法生成有效导图**: 请输出一个简单的Markdown列表,表示无法生成,例如:
|
||||
```markdown
|
||||
# 无法生成思维导图
|
||||
- 原因: 文本内容不足或不明确
|
||||
```
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_MINDMAP = """
|
||||
请分析以下长篇文本,并将其核心主题、关键概念、分支和子分支结构化为标准的Markdown列表语法,以供Markmap.js渲染。
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
Use code with caution.
|
||||
Python
|
||||
{long_text_content}
|
||||
"""
|
||||
|
||||
HTML_TEMPLATE_MINDMAP = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>智绘心图: 思维导图</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-lib@0.17"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.17"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1e88e5;
|
||||
--secondary-color: #43a047;
|
||||
--background-color: #f4f6f8;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #263238;
|
||||
--muted-text-color: #546e7a;
|
||||
--border-color: #e0e0e0;
|
||||
--header-gradient: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||||
--shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--border-radius: 12px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #eceff1;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.user-context span { margin: 4px 10px; }
|
||||
.content-area {
|
||||
padding: 30px 40px;
|
||||
}
|
||||
.markmap-container {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
background-image: radial-gradient(var(--border-color) 0.5px, transparent 0.5px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
min-height: 700px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
.download-area {
|
||||
text-align: center;
|
||||
padding-top: 30px;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.download-btn {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.download-btn.secondary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
.download-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.download-btn.copied {
|
||||
background-color: #2e7d32; /* A darker green for success */
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #90a4ae;
|
||||
background-color: #eceff1;
|
||||
}
|
||||
.footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error-message {
|
||||
color: #c62828;
|
||||
background-color: #ffcdd2;
|
||||
border: 1px solid #ef9a9a;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧠 智绘心图</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {user_name}</span>
|
||||
<span><strong>分析时间:</strong> {current_date_time_str}</span>
|
||||
<span><strong>星期:</strong> {current_weekday_zh}</span>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
||||
<div class="download-area">
|
||||
<button id="download-svg-btn-{unique_id}" class="download-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span class="btn-text">复制 SVG 代码</span>
|
||||
</button>
|
||||
<button id="download-md-btn-{unique_id}" class="download-btn secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span class="btn-text">复制 Markdown</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {current_year} 智绘心图 • 渲染引擎由 <a href="https://markmap.js.org/" target="_blank">Markmap</a> 提供</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const renderMindmap = () => {
|
||||
const uniqueId = "{unique_id}";
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
if (!containerEl || containerEl.dataset.markmapRendered) return;
|
||||
|
||||
const sourceEl = document.getElementById('markdown-source-' + uniqueId);
|
||||
if (!sourceEl) return;
|
||||
|
||||
const markdownContent = sourceEl.textContent.trim();
|
||||
if (!markdownContent) {
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图: 缺少有效内容。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svgEl.style.width = '100%';
|
||||
svgEl.style.height = '700px';
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.appendChild(svgEl);
|
||||
|
||||
const { Transformer, Markmap } = window.markmap;
|
||||
const transformer = new Transformer();
|
||||
const { root } = transformer.transform(markdownContent);
|
||||
|
||||
const style = (id) => `${id} text { font-size: 16px !important; }`;
|
||||
|
||||
const options = {
|
||||
autoFit: true,
|
||||
style: style
|
||||
};
|
||||
Markmap.create(svgEl, options, root);
|
||||
|
||||
containerEl.dataset.markmapRendered = 'true';
|
||||
|
||||
attachDownloadHandlers(uniqueId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Markmap rendering error:', error);
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 思维导图渲染失败!<br>原因: ' + error.message + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
const attachDownloadHandlers = (uniqueId) => {
|
||||
const downloadSvgBtn = document.getElementById('download-svg-btn-' + uniqueId);
|
||||
const downloadMdBtn = document.getElementById('download-md-btn-' + uniqueId);
|
||||
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
||||
|
||||
const showFeedback = (button, isSuccess) => {
|
||||
const buttonText = button.querySelector('.btn-text');
|
||||
const originalText = buttonText.textContent;
|
||||
|
||||
button.disabled = true;
|
||||
if (isSuccess) {
|
||||
buttonText.textContent = '✅ 已复制!';
|
||||
button.classList.add('copied');
|
||||
} else {
|
||||
buttonText.textContent = '❌ 复制失败';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
buttonText.textContent = originalText;
|
||||
button.disabled = false;
|
||||
button.classList.remove('copied');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const copyToClipboard = (content, button) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
showFeedback(button, true);
|
||||
}, () => {
|
||||
showFeedback(button, false);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older/insecure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = content;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showFeedback(button, true);
|
||||
} catch (err) {
|
||||
showFeedback(button, false);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
if (downloadSvgBtn) {
|
||||
downloadSvgBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const svgEl = containerEl.querySelector('svg');
|
||||
if (svgEl) {
|
||||
const svgData = new XMLSerializer().serializeToString(svgEl);
|
||||
copyToClipboard(svgData, downloadSvgBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadMdBtn) {
|
||||
downloadMdBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const markdownContent = document.getElementById('markdown-source-' + uniqueId).textContent;
|
||||
copyToClipboard(markdownContent, downloadMdBtn);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', renderMindmap);
|
||||
} else {
|
||||
renderMindmap();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=100, description="进行思维导图分析所需的最小文本长度(字符数)。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.weekday_map = {
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
}
|
||||
|
||||
def _extract_markdown_syntax(self, llm_output: str) -> str:
|
||||
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
extracted_content = match.group(1).strip()
|
||||
else:
|
||||
logger.warning(
|
||||
"LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。"
|
||||
)
|
||||
extracted_content = llm_output.strip()
|
||||
return extracted_content.replace("</script>", "<\\/script>")
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 智绘心图 (v12 - Final Feedback Fix) started")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
try:
|
||||
shanghai_tz = pytz.timezone("Asia/Shanghai")
|
||||
current_datetime_shanghai = datetime.now(shanghai_tz)
|
||||
current_date_time_str = current_datetime_shanghai.strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
current_weekday_en = current_datetime_shanghai.strftime("%A")
|
||||
current_weekday_zh = self.weekday_map.get(current_weekday_en, "未知星期")
|
||||
current_year = current_datetime_shanghai.strftime("%Y")
|
||||
current_timezone_str = "Asia/Shanghai"
|
||||
except Exception as e:
|
||||
logger.warning(f"获取时区信息失败: {e},使用默认值。")
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday_zh = "未知星期"
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "智绘心图已启动,正在为您生成思维导图...",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
messages = body.get("messages")
|
||||
if (
|
||||
not messages
|
||||
or not isinstance(messages, list)
|
||||
or not messages[-1].get("content")
|
||||
):
|
||||
error_message = "无法获取有效的用户消息内容。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "error", "content": error_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
|
||||
}
|
||||
|
||||
parts = re.split(r"```html.*?```", messages[-1]["content"], flags=re.DOTALL)
|
||||
long_text_content = ""
|
||||
if parts:
|
||||
for part in reversed(parts):
|
||||
if part.strip():
|
||||
long_text_content = part.strip()
|
||||
break
|
||||
|
||||
if not long_text_content:
|
||||
long_text_content = messages[-1]["content"].strip()
|
||||
|
||||
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(long_text_content)}字符),无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 深入分析文本结构...",
|
||||
"done": False,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
unique_id = f"id_{int(time.time() * 1000)}"
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_MINDMAP.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday_zh,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=long_text_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_MINDMAP_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
}
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象,用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
|
||||
if (
|
||||
not llm_response
|
||||
or "choices" not in llm_response
|
||||
or not llm_response["choices"]
|
||||
):
|
||||
raise ValueError("LLM响应格式不正确或为空。")
|
||||
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
||||
|
||||
final_html_content = (
|
||||
HTML_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
||||
.replace("{user_language}", user_language)
|
||||
.replace("{user_name}", user_name)
|
||||
.replace("{current_date_time_str}", current_date_time_str)
|
||||
.replace("{current_weekday_zh}", current_weekday_zh)
|
||||
.replace("{current_year}", current_year)
|
||||
.replace("{markdown_syntax}", markdown_syntax)
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 绘制完成!",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"思维导图已生成,{user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info("Action: 智绘心图 (v12) completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"智绘心图处理失败: {str(e)}"
|
||||
logger.error(f"智绘心图错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉,智绘心图在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{long_text_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "智绘心图: 处理失败。",
|
||||
"done": True,
|
||||
"hidden": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"智绘心图生成失败, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
15
plugins/actions/summary/README.md
Normal file
15
plugins/actions/summary/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Deep Reading & Summary
|
||||
|
||||
A powerful tool for analyzing long texts, generating detailed summaries, key points, and actionable insights.
|
||||
|
||||
## Features
|
||||
|
||||
- **Deep Analysis**: Goes beyond simple summarization to understand the core message.
|
||||
- **Key Point Extraction**: Identifies and lists the most important information.
|
||||
- **Actionable Advice**: Provides practical suggestions based on the text content.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. Send a long text or article to the chat.
|
||||
3. Click the "Deep Reading" button (or trigger via command).
|
||||
15
plugins/actions/summary/README_CN.md
Normal file
15
plugins/actions/summary/README_CN.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 深度阅读与摘要 (Deep Reading & Summary)
|
||||
|
||||
一个强大的长文本分析工具,用于生成详细摘要、关键信息点和可执行的行动建议。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **深度分析**:超越简单的总结,深入理解核心信息。
|
||||
- **关键点提取**:识别并列出最重要的信息点。
|
||||
- **行动建议**:基于文本内容提供切实可行的建议。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 发送长文本或文章到聊天框。
|
||||
3. 点击“精读”按钮(或通过命令触发)。
|
||||
527
plugins/actions/summary/summary.py
Normal file
527
plugins/actions/summary/summary.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
title: Deep Reading & Summary
|
||||
author: Antigravity
|
||||
author_url: https://github.com/open-webui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 0.1.0
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yIDNIMGEyIDIgMCAwIDAgMiAyIi8+PHBhdGggZD0iTTIyIDNIMjBhMiAyIDAgMCAwLTIgMiIvPjxwYXRoIGQ9Ik0yIDdoMjB2MTRhMiAyIDAgMCAxLTIgMmgtMTZhMiAyIDAgMCAxLTItMnYtMTQiLz48cGF0aCBkPSJNMTEgMTJ2NiIvPjxwYXRoIGQ9Ik0xNiAxMnY2Ii8+PHBhdGggZD0iTTYgMTJ2NiIvPjwvc3ZnPg==
|
||||
description: Provides deep reading analysis and summarization for long texts.
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# 内部 LLM 提示词设计
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_READING_ASSISTANT = """
|
||||
你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。
|
||||
|
||||
请提供以下内容:
|
||||
1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。
|
||||
2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该:
|
||||
- 具体且有深度
|
||||
- 包含必要的细节和背景
|
||||
- 使用 Markdown 列表格式
|
||||
3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该:
|
||||
- 明确且可操作
|
||||
- 包含执行的优先级或时间建议
|
||||
- 如果没有明确的行动项,可以提供学习建议或思考方向
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**:所有输出必须使用用户指定的语言。
|
||||
- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题:
|
||||
## 摘要
|
||||
[这里是详细的摘要内容,2-3段话,可以使用 Markdown 进行**加粗**或*斜体*强调重点]
|
||||
|
||||
## 关键信息点
|
||||
- [关键点1:包含具体细节和背景]
|
||||
- [关键点2:包含具体细节和背景]
|
||||
- [关键点3:包含具体细节和背景]
|
||||
- [至少5个,最多8个关键点]
|
||||
|
||||
## 行动建议
|
||||
- [行动项1:具体、可执行,包含优先级]
|
||||
- [行动项2:具体、可执行,包含优先级]
|
||||
- [如果没有明确行动项,提供学习建议或思考方向]
|
||||
- **深度优先**:分析要深入、全面,不要浮于表面。
|
||||
- **行动导向**:重点关注可执行的建议和下一步行动。
|
||||
- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_SUMMARY = """
|
||||
请对以下长篇文本进行深度分析,提供:
|
||||
1. 详细的摘要(2-3段话,全面概括文本内容)
|
||||
2. 关键信息点列表(5-8个,包含具体细节)
|
||||
3. 可执行的行动建议(具体、明确,包含优先级)
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
请进行深入、全面的分析,重点关注可执行的行动建议。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 前端 HTML 模板 (Jinja2 语法)
|
||||
# =================================================================
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ user_language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>精读:深度分析报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #1e88e5;
|
||||
--action-color: #34a853;
|
||||
--background-color: #f8f9fa;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #202124;
|
||||
--muted-text-color: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
|
||||
--shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
--border-radius: 8px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2.2em;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #f1f3f4;
|
||||
padding: 16px 40px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.user-context span { margin: 4px 12px; }
|
||||
.content { padding: 40px; }
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
}
|
||||
.section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
.section h2 .icon {
|
||||
margin-right: 12px;
|
||||
font-size: 1.3em;
|
||||
line-height: 1;
|
||||
}
|
||||
.summary-section h2 { border-bottom-color: var(--primary-color); }
|
||||
.keypoints-section h2 { border-bottom-color: var(--secondary-color); }
|
||||
.actions-section h2 { border-bottom-color: var(--action-color); }
|
||||
|
||||
.html-content {
|
||||
font-size: 1.05em;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.html-content p:first-child { margin-top: 0; }
|
||||
.html-content p:last-child { margin-bottom: 0; }
|
||||
.html-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.html-content li {
|
||||
padding: 12px 0 12px 32px;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.html-content li::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12px;
|
||||
font-family: 'Arial';
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.keypoints-section .html-content li::before {
|
||||
content: '•';
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.5em;
|
||||
top: 8px;
|
||||
}
|
||||
.actions-section .html-content li::before {
|
||||
content: '▸';
|
||||
color: var(--action-color);
|
||||
}
|
||||
|
||||
.no-content {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #5f6368;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📖 精读:深度分析报告</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {{ user_name }}</span>
|
||||
<span><strong>分析时间:</strong> {{ current_date_time_str }}</span>
|
||||
<span><strong>星期:</strong> {{ current_weekday }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section summary-section">
|
||||
<h2><span class="icon">📝</span>详细摘要</h2>
|
||||
<div class="html-content">{{ summary_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section keypoints-section">
|
||||
<h2><span class="icon">💡</span>关键信息点</h2>
|
||||
<div class="html-content">{{ keypoints_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section actions-section">
|
||||
<h2><span class="icon">🎯</span>行动建议</h2>
|
||||
<div class="html-content">{{ actions_html | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{ current_year }} 精读 - 深度文本分析服务</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200,
|
||||
description="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。",
|
||||
)
|
||||
RECOMMENDED_MIN_LENGTH: int = Field(
|
||||
default=500, description="建议的最小文本长度,以获得最佳分析效果。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""
|
||||
解析LLM的Markdown输出,将其转换为HTML片段。
|
||||
"""
|
||||
summary_match = re.search(
|
||||
r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
keypoints_match = re.search(
|
||||
r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
actions_match = re.search(
|
||||
r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
|
||||
summary_md = summary_match.group(1).strip() if summary_match else ""
|
||||
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
|
||||
actions_md = actions_match.group(1).strip() if actions_match else ""
|
||||
|
||||
if not any([summary_md, keypoints_md, actions_md]):
|
||||
summary_md = llm_output.strip()
|
||||
logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。")
|
||||
|
||||
# 使用 'nl2br' 扩展将换行符 \n 转换为 <br>
|
||||
md_extensions = ["nl2br"]
|
||||
summary_html = (
|
||||
markdown.markdown(summary_md, extensions=md_extensions)
|
||||
if summary_md
|
||||
else '<p class="no-content">未能提取摘要信息。</p>'
|
||||
)
|
||||
keypoints_html = (
|
||||
markdown.markdown(keypoints_md, extensions=md_extensions)
|
||||
if keypoints_md
|
||||
else '<p class="no-content">未能提取关键信息点。</p>'
|
||||
)
|
||||
actions_html = (
|
||||
markdown.markdown(actions_md, extensions=md_extensions)
|
||||
if actions_md
|
||||
else '<p class="no-content">暂无明确的行动建议。</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"summary_html": summary_html,
|
||||
"keypoints_html": keypoints_html,
|
||||
"actions_html": actions_html,
|
||||
}
|
||||
|
||||
def _build_html(self, context: dict) -> str:
|
||||
"""
|
||||
使用 Jinja2 模板和上下文数据构建最终的HTML内容。
|
||||
"""
|
||||
template = Template(HTML_TEMPLATE)
|
||||
return template.render(context)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday = now.strftime("%A")
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages or not messages[-1].get("content"):
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
|
||||
original_content = messages[-1]["content"]
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
# Recommend for longer texts
|
||||
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "📖 精读已启动,正在进行深度分析...",
|
||||
},
|
||||
}
|
||||
)
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "📖 精读: 深入分析文本,提炼精华...",
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象, 用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
|
||||
processed_content = self._process_llm_output(assistant_response_content)
|
||||
|
||||
context = {
|
||||
"user_language": user_language,
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"current_weekday": current_weekday,
|
||||
"current_year": current_year,
|
||||
**processed_content,
|
||||
}
|
||||
|
||||
final_html_content = self._build_html(context)
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "📖 精读: 分析完成!", "done": True},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"📖 精读完成,{user_name}!深度分析报告已生成。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"精读处理失败: {str(e)}"
|
||||
logger.error(f"精读错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "精读: 处理失败。",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"精读处理失败, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
521
plugins/actions/summary/精读.py
Normal file
521
plugins/actions/summary/精读.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
title: 精读 (Deep Reading)
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNDI4NWY0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWU4OGU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTE0IDJsNiA2aC02eiIgZmlsbD0iIzFlODhlNSIgb3BhY2l0eT0iMC42Ii8+PGxpbmUgeDE9IjgiIHkxPSIxMyIgeDI9IjE2IiB5Mj0iMTMiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48bGluZSB4MT0iOCIgeTE9IjE3IiB4Mj0iMTQiIHkyPSIxNyIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiNmZmQ3MDAiLz48cGF0aCBkPSJNMTYgMTZsMS41IDEuNSIgc3Ryb2tlPSIjNDI4NWY0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==
|
||||
version: 2.0.0
|
||||
description: 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议,适合工作和学习场景。
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# 内部 LLM 提示词设计
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_READING_ASSISTANT = """
|
||||
你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。
|
||||
|
||||
请提供以下内容:
|
||||
1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。
|
||||
2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该:
|
||||
- 具体且有深度
|
||||
- 包含必要的细节和背景
|
||||
- 使用 Markdown 列表格式
|
||||
3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该:
|
||||
- 明确且可操作
|
||||
- 包含执行的优先级或时间建议
|
||||
- 如果没有明确的行动项,可以提供学习建议或思考方向
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**:所有输出必须使用用户指定的语言。
|
||||
- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题:
|
||||
## 摘要
|
||||
[这里是详细的摘要内容,2-3段话,可以使用 Markdown 进行**加粗**或*斜体*强调重点]
|
||||
|
||||
## 关键信息点
|
||||
- [关键点1:包含具体细节和背景]
|
||||
- [关键点2:包含具体细节和背景]
|
||||
- [关键点3:包含具体细节和背景]
|
||||
- [至少5个,最多8个关键点]
|
||||
|
||||
## 行动建议
|
||||
- [行动项1:具体、可执行,包含优先级]
|
||||
- [行动项2:具体、可执行,包含优先级]
|
||||
- [如果没有明确行动项,提供学习建议或思考方向]
|
||||
- **深度优先**:分析要深入、全面,不要浮于表面。
|
||||
- **行动导向**:重点关注可执行的建议和下一步行动。
|
||||
- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_SUMMARY = """
|
||||
请对以下长篇文本进行深度分析,提供:
|
||||
1. 详细的摘要(2-3段话,全面概括文本内容)
|
||||
2. 关键信息点列表(5-8个,包含具体细节)
|
||||
3. 可执行的行动建议(具体、明确,包含优先级)
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
请进行深入、全面的分析,重点关注可执行的行动建议。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 前端 HTML 模板 (Jinja2 语法)
|
||||
# =================================================================
|
||||
|
||||
HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ user_language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>精读:深度分析报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #1e88e5;
|
||||
--action-color: #34a853;
|
||||
--background-color: #f8f9fa;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #202124;
|
||||
--muted-text-color: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
|
||||
--shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
--border-radius: 8px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 20px auto;
|
||||
background: var(--card-bg-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 2.2em;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.user-context {
|
||||
font-size: 0.9em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #f1f3f4;
|
||||
padding: 16px 40px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.user-context span { margin: 4px 12px; }
|
||||
.content { padding: 40px; }
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
}
|
||||
.section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
.section h2 .icon {
|
||||
margin-right: 12px;
|
||||
font-size: 1.3em;
|
||||
line-height: 1;
|
||||
}
|
||||
.summary-section h2 { border-bottom-color: var(--primary-color); }
|
||||
.keypoints-section h2 { border-bottom-color: var(--secondary-color); }
|
||||
.actions-section h2 { border-bottom-color: var(--action-color); }
|
||||
|
||||
.html-content {
|
||||
font-size: 1.05em;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.html-content p:first-child { margin-top: 0; }
|
||||
.html-content p:last-child { margin-bottom: 0; }
|
||||
.html-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.html-content li {
|
||||
padding: 12px 0 12px 32px;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.html-content li::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12px;
|
||||
font-family: 'Arial';
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.keypoints-section .html-content li::before {
|
||||
content: '•';
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.5em;
|
||||
top: 8px;
|
||||
}
|
||||
.actions-section .html-content li::before {
|
||||
content: '▸';
|
||||
color: var(--action-color);
|
||||
}
|
||||
|
||||
.no-content {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 0.85em;
|
||||
color: #5f6368;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📖 精读:深度分析报告</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {{ user_name }}</span>
|
||||
<span><strong>分析时间:</strong> {{ current_date_time_str }}</span>
|
||||
<span><strong>星期:</strong> {{ current_weekday }}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section summary-section">
|
||||
<h2><span class="icon">📝</span>详细摘要</h2>
|
||||
<div class="html-content">{{ summary_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section keypoints-section">
|
||||
<h2><span class="icon">💡</span>关键信息点</h2>
|
||||
<div class="html-content">{{ keypoints_html | safe }}</div>
|
||||
</div>
|
||||
<div class="section actions-section">
|
||||
<h2><span class="icon">🎯</span>行动建议</h2>
|
||||
<div class="html-content">{{ actions_html | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{ current_year }} 精读 - 深度文本分析服务</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
show_status: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
LLM_MODEL_ID: str = Field(
|
||||
default="gemini-2.5-flash",
|
||||
description="用于文本分析的内置LLM模型ID。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200, description="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。"
|
||||
)
|
||||
RECOMMENDED_MIN_LENGTH: int = Field(
|
||||
default=500, description="建议的最小文本长度,以获得最佳分析效果。"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""
|
||||
解析LLM的Markdown输出,将其转换为HTML片段。
|
||||
"""
|
||||
summary_match = re.search(
|
||||
r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
keypoints_match = re.search(
|
||||
r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
actions_match = re.search(
|
||||
r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
|
||||
summary_md = summary_match.group(1).strip() if summary_match else ""
|
||||
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
|
||||
actions_md = actions_match.group(1).strip() if actions_match else ""
|
||||
|
||||
if not any([summary_md, keypoints_md, actions_md]):
|
||||
summary_md = llm_output.strip()
|
||||
logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。")
|
||||
|
||||
# 使用 'nl2br' 扩展将换行符 \n 转换为 <br>
|
||||
md_extensions = ["nl2br"]
|
||||
summary_html = (
|
||||
markdown.markdown(summary_md, extensions=md_extensions)
|
||||
if summary_md
|
||||
else '<p class="no-content">未能提取摘要信息。</p>'
|
||||
)
|
||||
keypoints_html = (
|
||||
markdown.markdown(keypoints_md, extensions=md_extensions)
|
||||
if keypoints_md
|
||||
else '<p class="no-content">未能提取关键信息点。</p>'
|
||||
)
|
||||
actions_html = (
|
||||
markdown.markdown(actions_md, extensions=md_extensions)
|
||||
if actions_md
|
||||
else '<p class="no-content">暂无明确的行动建议。</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"summary_html": summary_html,
|
||||
"keypoints_html": keypoints_html,
|
||||
"actions_html": actions_html,
|
||||
}
|
||||
|
||||
def _build_html(self, context: dict) -> str:
|
||||
"""
|
||||
使用 Jinja2 模板和上下文数据构建最终的HTML内容。
|
||||
"""
|
||||
template = Template(HTML_TEMPLATE)
|
||||
return template.render(context)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday = now.strftime("%A")
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages or not messages[-1].get("content"):
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
|
||||
original_content = messages[-1]["content"]
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {"type": "warning", "content": short_text_message},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
# Recommend for longer texts
|
||||
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "info",
|
||||
"content": "📖 精读已启动,正在进行深度分析...",
|
||||
},
|
||||
}
|
||||
)
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "📖 精读: 深入分析文本,提炼精华...",
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
llm_payload = {
|
||||
"model": self.valves.LLM_MODEL_ID,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象, 用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
|
||||
processed_content = self._process_llm_output(assistant_response_content)
|
||||
|
||||
context = {
|
||||
"user_language": user_language,
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"current_weekday": current_weekday,
|
||||
"current_year": current_year,
|
||||
**processed_content,
|
||||
}
|
||||
|
||||
final_html_content = self._build_html(context)
|
||||
html_embed_tag = f"```html\n{final_html_content}\n```"
|
||||
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
|
||||
|
||||
if self.valves.show_status and __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "📖 精读: 分析完成!", "done": True},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "success",
|
||||
"content": f"📖 精读完成,{user_name}!深度分析报告已生成。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"精读处理失败: {str(e)}"
|
||||
logger.error(f"精读错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
if __event_emitter__:
|
||||
if self.valves.show_status:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": "精读: 处理失败。",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "notification",
|
||||
"data": {
|
||||
"type": "error",
|
||||
"content": f"精读处理失败, {user_name}!",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return body
|
||||
Reference in New Issue
Block a user