Compare commits

...

51 Commits

Author SHA1 Message Date
fujie
24e7d34524 fix: robust version determination in release workflow 2026-01-08 00:25:37 +08:00
fujie
a58ce9e99e feat: 为所有插件配置添加 openwebui_id。 2026-01-08 00:16:56 +08:00
fujie
4a42dcf8de chore: update extract_plugin_versions.py script 2026-01-08 00:14:32 +08:00
fujie
5903ea0e40 docs: update plugin development workflow with market publishing steps 2026-01-08 00:13:23 +08:00
fujie
6d7a5b45cf feat: bump export_to_word to v0.4.3 and automate plugin publishing 2026-01-08 00:12:17 +08:00
github-actions[bot]
10433d38b3 chore: update community stats 2026-01-07 2026-01-07 16:11:43 +00:00
github-actions[bot]
bf2bc80b22 chore: update community stats 2026-01-07 2026-01-07 15:10:07 +00:00
fujie
1e0f5fb65a feat: improve release workflow and update community stats to Top 6 2026-01-07 22:35:24 +08:00
github-actions[bot]
7d5a696106 📊 更新社区统计数据 2026-01-07 2026-01-07 14:09:37 +00:00
fujie
cf86012d4d feat(infographic): upload PNG instead of SVG for better compatibility
- Convert SVG to PNG using canvas before uploading
- 2x scale for higher quality output
- Fix Word export compatibility issue (SVG not supported by python-docx)
- Update version to 1.4.1
- Update README.md and README_CN.md with new feature
2026-01-07 21:24:09 +08:00
github-actions[bot]
961c1cbca6 📊 更新社区统计数据 2026-01-07 2026-01-07 13:20:25 +00:00
fujie
7fb5c243fa feat(export-to-word): add S3 object storage support
- Add boto3 direct download for S3/MinIO stored images
- Implement 6-level file fallback: DB → S3 → Local → URL → API → Attributes
- Sync S3 support to Chinese version (export_to_word_cn.py)
- Update version to 0.4.2
- Rewrite README.md and README_CN.md following standard format
- Update docs version numbers
- Add file storage access guidelines to copilot-instructions.md
2026-01-07 20:59:33 +08:00
github-actions[bot]
f845281b72 📊 更新社区统计数据 2026-01-07 2026-01-07 12:14:53 +00:00
github-actions[bot]
0b2c6a2d36 📊 更新社区统计数据 2026-01-07 2026-01-07 11:08:40 +00:00
github-actions[bot]
245c37b2c3 📊 更新社区统计数据 2026-01-07 2026-01-07 10:09:52 +00:00
github-actions[bot]
d2a915a514 📊 更新社区统计数据 2026-01-07 2026-01-07 09:12:43 +00:00
github-actions[bot]
ae731f9bd6 📊 更新社区统计数据 2026-01-07 2026-01-07 08:11:59 +00:00
github-actions[bot]
2a8a8c5805 📊 更新社区统计数据 2026-01-07 2026-01-07 07:11:58 +00:00
github-actions[bot]
deb1272f62 📊 更新社区统计数据 2026-01-07 2026-01-07 06:13:00 +00:00
github-actions[bot]
51c41b8628 📊 更新社区统计数据 2026-01-07 2026-01-07 05:12:07 +00:00
github-actions[bot]
37893ded00 📊 更新社区统计数据 2026-01-07 2026-01-07 04:23:26 +00:00
github-actions[bot]
38fe50a898 📊 更新社区统计数据 2026-01-07 2026-01-07 03:37:14 +00:00
github-actions[bot]
1c731e70dc 📊 更新社区统计数据 2026-01-07 2026-01-07 02:46:45 +00:00
github-actions[bot]
a55aa4d8fd 📊 更新社区统计数据 2026-01-07 2026-01-07 01:37:09 +00:00
github-actions[bot]
6c79cb2f11 📊 更新社区统计数据 2026-01-07 2026-01-07 00:35:06 +00:00
fujie
ba7943bd6f fix: restore responsive sizing for infographic 2026-01-07 07:32:59 +08:00
fujie
6eb09c3eaa fix: use fixed dimensions to prevent title wrapping 2026-01-07 07:31:13 +08:00
fujie
63c5257162 fix: reduce infographic padding to prevent title wrapping 2026-01-07 07:12:46 +08:00
fujie
a2422262b5 fix: increase infographic width to prevent title wrapping 2026-01-07 07:09:26 +08:00
github-actions[bot]
4f49b111fd 📊 更新社区统计数据 2026-01-06 2026-01-06 23:08:20 +00:00
fujie
1d066fc1f0 fix: reduce infographic size and adjust layout margins 2026-01-07 07:05:20 +08:00
github-actions[bot]
e960c40351 📊 更新社区统计数据 2026-01-06 2026-01-06 22:08:33 +00:00
github-actions[bot]
96284a3652 📊 更新社区统计数据 2026-01-06 2026-01-06 21:08:09 +00:00
github-actions[bot]
ad2f38ec1f 📊 更新社区统计数据 2026-01-06 2026-01-06 20:09:22 +00:00
github-actions[bot]
87fc34d505 📊 更新社区统计数据 2026-01-06 2026-01-06 19:06:41 +00:00
github-actions[bot]
2aafd3cef7 📊 更新社区统计数据 2026-01-06 2026-01-06 18:12:20 +00:00
github-actions[bot]
afec54c4e0 📊 更新社区统计数据 2026-01-06 2026-01-06 17:10:30 +00:00
github-actions[bot]
905a9e67ca 📊 更新社区统计数据 2026-01-06 2026-01-06 16:10:24 +00:00
github-actions[bot]
ce56815e77 📊 更新社区统计数据 2026-01-06 2026-01-06 15:09:05 +00:00
fujie
2684098be1 docs: update doc standards & reformat infographic readme; feat: default image mode 2026-01-06 22:57:17 +08:00
fujie
57ebf24c75 feat: update Smart Infographic to v1.4.0 with static image output support 2026-01-06 22:35:46 +08:00
github-actions[bot]
9375df709f 📊 更新社区统计数据 2026-01-06 2026-01-06 14:09:06 +00:00
fujie
255e48bd33 docs(smart-mind-map): add comparison table for output modes 2026-01-06 21:50:22 +08:00
fujie
18993c7fbe docs(smart-mind-map): emphasize no HTML output in image mode 2026-01-06 21:46:22 +08:00
fujie
f3cf2b52fd docs(smart-mind-map): highlight v0.9.1 features in README header 2026-01-06 21:39:42 +08:00
fujie
856f76cd27 feat(smart-mind-map): v0.9.1 - Add Image output mode with file upload support 2026-01-06 21:35:36 +08:00
github-actions[bot]
28bb9000d8 📊 更新社区统计数据 2026-01-06 2026-01-06 13:19:34 +00:00
github-actions[bot]
d0b9e46b74 📊 更新社区统计数据 2026-01-06 2026-01-06 12:14:33 +00:00
fujie
a0a4d31715 📝 版本号改为当天发布次数计数 2026-01-06 19:41:22 +08:00
fujie
d5f394f5f1 🐛 修复 README.md 中的重复统计数据 2026-01-06 19:40:21 +08:00
fujie
a477d2baad 🔧 移除时间显示中的时区标注 2026-01-06 19:38:54 +08:00
39 changed files with 3061 additions and 667 deletions

View File

@@ -25,6 +25,10 @@ Every plugin **MUST** have bilingual versions for both code and documentation:
- **Valves**: Use `pydantic` for configuration.
- **Database**: Re-use `open_webui.internal.db` shared connection.
- **User Context**: Use `_get_user_context` helper method.
- **Chat API**: For message updates, follow the "OpenWebUI Chat API 更新规范" in `.github/copilot-instructions.md`.
- Use Event API for immediate UI updates
- Use Chat Persistence API for database storage
- Always update both `messages[]` and `history.messages`
### Commit Messages
- **Language**: **English ONLY**. Do not use Chinese in commit messages.
@@ -35,8 +39,8 @@ Every plugin **MUST** have bilingual versions for both code and documentation:
When adding or updating a plugin, you **MUST** update the following documentation files to maintain consistency:
### Plugin Directory
- `README.md`: Update version, description, and usage. **Explicitly describe new features.**
- `README_CN.md`: Update version, description, and usage. **Explicitly describe new features.**
- `README.md`: Update version, description, and usage. **Explicitly describe new features in a prominent position at the beginning.**
- `README_CN.md`: Update version, description, and usage. **Explicitly describe new features in a prominent position at the beginning.**
### Global Documentation (`docs/`)
- **Index Pages**:
@@ -78,6 +82,11 @@ Reference: `.github/workflows/release.yml`
- Generates release notes based on changes.
- Creates a GitHub Release tag (e.g., `v2024.01.01-1`).
- Uploads individual `.py` files of **changed plugins only** as assets.
4. **Market Publishing**:
- Workflow: `.github/workflows/publish_plugin.yml`
- Trigger: Release published.
- Action: Automatically updates the plugin code and metadata on OpenWebUI.com using `scripts/publish_plugin.py`.
- Requirement: `OPENWEBUI_API_KEY` secret must be set.
### Pull Request Check
- Workflow: `.github/workflows/plugin-version-check.yml`

View File

@@ -31,15 +31,75 @@ plugins/actions/export_to_docx/
- `README.md` - English documentation
- `README_CN.md` - 中文文档
README 文件应包含以下内容:
- 功能描述 / Feature description
- 配置参数及默认值 / Configuration parameters with defaults
- 安装和设置说明 / Installation and setup instructions
- 使用示例 / Usage examples
- 故障排除指南 / Troubleshooting guide
- 故障排除指南 / Troubleshooting guide
- 版本和作者信息 / Version and author information
- **新增功能 / New Features**: 如果是更新现有插件,必须明确列出并描述新增功能(发布到官方市场的重要要求)。/ If updating an existing plugin, explicitly list and describe new features (Critical for official market release).
### README 结构规范 (README Structure Standard)
所有插件 README 必须遵循以下统一结构顺序:
1. **标题 (Title)**: 插件名称,带 Emoji 图标
2. **元数据 (Metadata)**: 作者、版本、项目链接 (一行显示)
- 格式: `**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** x.x.x | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)`
- **注意**: Author 和 Project 为固定值,仅需更新 Version 版本号
3. **描述 (Description)**: 一句话功能介绍
4. **最新更新 (What's New)**: **必须**放在描述之后,显著展示最新版本的变更点
5. **核心特性 (Key Features)**: 使用 Emoji + 粗体标题 + 描述格式
6. **使用方法 (How to Use)**: 按步骤说明
7. **配置参数 (Configuration/Valves)**: 使用表格格式,包含参数名、默认值、描述
8. **其他 (Others)**: 支持的模板类型、语法示例、故障排除等
完整示例 (Full Example):
```markdown
# 📊 Smart Plugin
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.0.0 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
A one-sentence description of this plugin.
## 🔥 What's New in v1.0.0
-**Feature Name**: Brief description of the feature.
- 🔧 **Configuration Change**: What changed in settings.
- 🐛 **Bug Fix**: What was fixed.
## ✨ Key Features
- 🚀 **Feature A**: Description of feature A.
- 🎨 **Feature B**: Description of feature B.
- 📥 **Feature C**: Description of feature C.
## 🚀 How to Use
1. **Install**: Search for "Plugin Name" in the Open WebUI Community and install.
2. **Trigger**: Enter your text in the chat, then click the **Action Button**.
3. **Result**: View the generated result.
## ⚙️ Configuration (Valves)
| Parameter | Default | Description |
| :--- | :--- | :--- |
| **Show Status (SHOW_STATUS)** | `True` | Whether to show status updates. |
| **Model ID (MODEL_ID)** | `Empty` | LLM model for processing. |
| **Output Mode (OUTPUT_MODE)** | `image` | `image` for static, `html` for interactive. |
## 🛠️ Supported Types (Optional)
| Category | Type Name | Use Case |
| :--- | :--- | :--- |
| **Category A** | `type-a`, `type-b` | Use case description |
## 📝 Advanced Example (Optional)
\`\`\`syntax
example code or syntax here
\`\`\`
```
### 文档内容要求 (Content Requirements)
- **新增功能**: 必须在 "What's New" 章节中明确列出,使用 Emoji + 粗体标题格式。
- **双语**: 必须提供 `README.md` (英文) 和 `README_CN.md` (中文)。
- **表格对齐**: 配置参数表格使用左对齐 `:---`
- **Emoji 规范**: 标题使用合适的 Emoji 增强可读性。
### 官方文档 (Official Documentation)
@@ -93,33 +153,7 @@ icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...(完整的 Base64 编
---
## 👤 作者和许可证信息 (Author and License)
所有 README 文件和主要文档必须包含以下统一信息:
```markdown
## Author
Fu-Jie
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## License
MIT License
```
中文版本:
```markdown
## 作者
Fu-Jie
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## 许可证
MIT License
```
(Author info is now part of the top metadata section, see "README Structure Standard" above)
---
@@ -507,7 +541,164 @@ Base = declarative_base()
---
## 🔧 代码规范 (Code Style)
## 📂 文件存储访问规范 (File Storage Access)
OpenWebUI 支持多种文件存储后端本地磁盘、S3/MinIO 对象存储等)。插件在访问用户上传的文件或生成的图片时,必须实现多级回退机制以兼容所有存储配置。
### 存储类型检测 (Storage Type Detection)
通过 `Files.get_file_by_id()` 获取的文件对象,其 `path` 属性决定了存储位置:
| Path 格式 | 存储类型 | 访问方式 |
|-----------|----------|----------|
| `s3://bucket/key` | S3/MinIO 对象存储 | boto3 直连或 API 回调 |
| `/app/backend/data/...` | Docker 卷存储 | 本地文件系统读取 |
| `./uploads/...` | 本地相对路径 | 本地文件系统读取 |
| `gs://bucket/key` | Google Cloud Storage | API 回调 |
### 多级回退机制 (Multi-level Fallback)
推荐实现以下优先级的文件获取策略:
```python
def _get_file_content(self, file_id: str, max_bytes: int) -> Optional[bytes]:
"""获取文件内容,支持多种存储后端"""
file_obj = Files.get_file_by_id(file_id)
if not file_obj:
return None
# 1⃣ 数据库直接存储 (小文件)
data_field = getattr(file_obj, "data", None)
if isinstance(data_field, dict):
if "bytes" in data_field:
return data_field["bytes"]
if "base64" in data_field:
return base64.b64decode(data_field["base64"])
# 2⃣ S3 直连 (对象存储 - 最快)
s3_path = getattr(file_obj, "path", None)
if isinstance(s3_path, str) and s3_path.startswith("s3://"):
data = self._read_from_s3(s3_path, max_bytes)
if data:
return data
# 3⃣ 本地文件系统 (磁盘存储)
for attr in ("path", "file_path"):
path = getattr(file_obj, attr, None)
if path and not path.startswith(("s3://", "gs://", "http")):
# 尝试多个常见路径
for base in ["", "./data", "/app/backend/data"]:
full_path = Path(base) / path if base else Path(path)
if full_path.exists():
return full_path.read_bytes()[:max_bytes]
# 4⃣ 公共 URL 下载
url = getattr(file_obj, "url", None)
if url and url.startswith("http"):
return self._download_from_url(url, max_bytes)
# 5⃣ 内部 API 回调 (通用兜底方案)
if self._api_base_url:
api_url = f"{self._api_base_url}/api/v1/files/{file_id}/content"
return self._download_from_api(api_url, self._api_token, max_bytes)
return None
```
### S3 直连实现 (S3 Direct Access)
当检测到 `s3://` 路径时,使用 `boto3` 直接访问对象存储,读取以下环境变量:
| 环境变量 | 说明 | 示例 |
|----------|------|------|
| `S3_ENDPOINT_URL` | S3 兼容服务端点 | `https://minio.example.com` |
| `S3_ACCESS_KEY_ID` | 访问密钥 ID | `minioadmin` |
| `S3_SECRET_ACCESS_KEY` | 访问密钥 | `minioadmin` |
| `S3_ADDRESSING_STYLE` | 寻址样式 | `auto`, `path`, `virtual` |
```python
# S3 直连示例
import boto3
from botocore.config import Config as BotoConfig
import os
def _read_from_s3(self, s3_path: str, max_bytes: int) -> Optional[bytes]:
"""从 S3 直接读取文件 (比 API 回调更快)"""
if not s3_path.startswith("s3://"):
return None
# 解析 s3://bucket/key
parts = s3_path[5:].split("/", 1)
bucket, key = parts[0], parts[1]
# 从环境变量读取配置
endpoint = os.environ.get("S3_ENDPOINT_URL")
access_key = os.environ.get("S3_ACCESS_KEY_ID")
secret_key = os.environ.get("S3_SECRET_ACCESS_KEY")
if not all([endpoint, access_key, secret_key]):
return None # 回退到 API 方式
s3_client = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=BotoConfig(s3={"addressing_style": os.environ.get("S3_ADDRESSING_STYLE", "auto")})
)
response = s3_client.get_object(Bucket=bucket, Key=key)
return response["Body"].read(max_bytes)
```
### API 回调实现 (API Fallback)
当其他方式失败时,通过 OpenWebUI 内部 API 获取文件:
```python
def _download_from_api(self, api_url: str, token: str, max_bytes: int) -> Optional[bytes]:
"""通过 OpenWebUI API 获取文件内容"""
import urllib.request
headers = {"User-Agent": "OpenWebUI-Plugin"}
if token:
headers["Authorization"] = token
req = urllib.request.Request(api_url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as response:
if 200 <= response.status < 300:
return response.read(max_bytes)
return None
```
### 获取 API 上下文 (API Context Extraction)
`action()` 方法中捕获请求上下文,用于 API 回调:
```python
async def action(self, body: dict, __request__=None, ...):
# 从请求对象获取 API 凭证
if __request__:
self._api_token = __request__.headers.get("Authorization")
self._api_base_url = str(__request__.base_url).rstrip("/")
else:
# 从环境变量获取端口作为备用
port = os.environ.get("PORT") or "8080"
self._api_base_url = f"http://localhost:{port}"
self._api_token = None
```
### 性能对比 (Performance Comparison)
| 方式 | 网络跳数 | 适用场景 |
|------|----------|----------|
| S3 直连 | 1 (插件 → S3) | 对象存储,最快 |
| 本地文件 | 0 | 磁盘存储,最快 |
| API 回调 | 2 (插件 → OpenWebUI → S3/磁盘) | 通用兜底 |
### 参考实现 (Reference Implementation)
- `plugins/actions/export_to_docx/export_to_word.py` - `_image_bytes_from_owui_file_id` 方法
### Python 规范
@@ -949,7 +1140,7 @@ async def action(self, body, __event_call__, __metadata__, ...):
#### 优势
- **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块
- **自包含**:图片以 Base64 Data URL 嵌入,无外部依赖
- **高效存储**:图片上传至 `/api/v1/files`,避免 Base64 字符串膨胀聊天记录
- **持久化**:通过 API 回写,消息重新加载后图片仍然存在
- **跨平台**:任何支持 Markdown 图片的客户端都能显示
- **无服务端渲染依赖**:利用用户浏览器的渲染能力
@@ -960,7 +1151,7 @@ async def action(self, body, __event_call__, __metadata__, ...):
|------|-------------------------|------------------------|
| 输出格式 | HTML 代码块 | Markdown 图片 |
| 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 |
| 外部依赖 | 需要加载 JS 库 | 无(图片自包含) |
| 外部依赖 | 需要加载 JS 库 | 依赖 `/api/v1/files` 存储 |
| 持久化 | 依赖浏览器渲染 | ✅ 永久可见 |
| 文件导出 | 需特殊处理 | ✅ 直接导出 |
| 适用场景 | 交互式内容 | 信息图、图表快照 |
@@ -970,7 +1161,199 @@ async def action(self, body, __event_call__, __metadata__, ...):
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV Infographic 生成并嵌入
- `plugins/actions/js-render-poc/js_render_poc.py` - 基础概念验证
### OpenWebUI Chat API 更新规范 (Chat API Update Specification)
当插件需要修改消息内容并持久化到数据库时,必须遵循 OpenWebUI 的 Backend-Controlled API 流程。
When a plugin needs to modify message content and persist it to the database, follow OpenWebUI's Backend-Controlled API flow.
#### 核心概念 (Core Concepts)
1. **Event API** (`/api/v1/chats/{chatId}/messages/{messageId}/event`)
- 用于**即时更新前端显示**,用户无需刷新页面
- 是可选的,部分版本可能不支持
- 仅影响当前会话的 UI不持久化
2. **Chat Persistence API** (`/api/v1/chats/{chatId}`)
- 用于**持久化到数据库**,确保刷新页面后数据仍存在
- 必须同时更新 `messages[]` 数组和 `history.messages` 对象
- 是消息持久化的唯一可靠方式
#### 数据结构 (Data Structure)
OpenWebUI 的 Chat 对象包含两个关键位置存储消息内容:
```javascript
{
"chat": {
"id": "chat-uuid",
"title": "Chat Title",
"messages": [ // 1⃣ 消息数组
{ "id": "msg-1", "role": "user", "content": "..." },
{ "id": "msg-2", "role": "assistant", "content": "..." }
],
"history": {
"current_id": "msg-2",
"messages": { // 2⃣ 消息索引对象
"msg-1": { "id": "msg-1", "role": "user", "content": "..." },
"msg-2": { "id": "msg-2", "role": "assistant", "content": "..." }
}
}
}
}
```
> **重要**:修改消息时,**必须同时更新两个位置**,否则可能导致数据不一致。
#### 标准实现流程 (Standard Implementation)
```javascript
(async function() {
const chatId = "{chat_id}";
const messageId = "{message_id}";
const token = localStorage.getItem("token");
// 1⃣ 获取当前 Chat 数据
const getResponse = await fetch(`/api/v1/chats/${chatId}`, {
method: "GET",
headers: { "Authorization": `Bearer ${token}` }
});
const chatData = await getResponse.json();
// 2⃣ 使用 map 遍历 messages只修改目标消息
let newContent = "";
const updatedMessages = chatData.chat.messages.map(m => {
if (m.id === messageId) {
const originalContent = m.content || "";
newContent = originalContent + "\n\n" + newMarkdown;
// 3⃣ 同时更新 history.messages 中对应的消息
if (chatData.chat.history && chatData.chat.history.messages) {
if (chatData.chat.history.messages[messageId]) {
chatData.chat.history.messages[messageId].content = newContent;
}
}
// 4⃣ 保留消息的其他属性,只修改 content
return { ...m, content: newContent };
}
return m; // 其他消息原样返回
});
// 5⃣ 通过 Event API 即时更新前端(可选)
try {
await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
type: "chat:message",
data: { content: newContent }
})
});
} catch (e) {
// Event API 是可选的,继续执行持久化
console.log("Event API not available, continuing...");
}
// 6⃣ 持久化到数据库(必须)
const updatePayload = {
chat: {
...chatData.chat, // 保留所有原有属性
messages: updatedMessages
// history 已在上面原地修改
}
};
await fetch(`/api/v1/chats/${chatId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(updatePayload)
});
})();
```
#### 最佳实践 (Best Practices)
1. **保留原有结构**:使用展开运算符 `...chatData.chat` 和 `...m` 确保不丢失任何原有属性
2. **双位置更新**:必须同时更新 `messages[]` 和 `history.messages[id]`
3. **错误处理**Event API 调用应包裹在 try-catch 中,失败时继续持久化
4. **重试机制**:对持久化 API 实现重试逻辑,提高可靠性
```javascript
// 带重试的请求函数
const fetchWithRetry = async (url, options, retries = 3) => {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {
await new Promise(r => setTimeout(r, 1000 * (i + 1))); // 指数退避
}
} catch (e) {
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
return null;
};
```
5. **禁止使用的 API**:不要使用 `/api/v1/chats/{chatId}/share` 作为持久化备用方案,该 API 用于分享功能,不是更新功能
#### 提取 Chat ID 和 Message ID (Extracting IDs)
```python
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
"""从 body 或 metadata 中提取 chat_id"""
if isinstance(body, dict):
chat_id = body.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
chat_id = body_metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
if isinstance(metadata, dict):
chat_id = metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
return ""
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
"""从 body 或 metadata 中提取 message_id"""
if isinstance(body, dict):
message_id = body.get("id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
message_id = body_metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
if isinstance(metadata, dict):
message_id = metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
return ""
```
#### 参考实现
- `plugins/actions/smart-mind-map/smart_mind_map.py` - 思维导图图片模式实现
- 官方文档: [Backend-Controlled, UI-Compatible API Flow](https://docs.openwebui.com/tutorials/tips/backend-controlled-ui-compatible-api-flow)
---

View File

@@ -42,13 +42,13 @@ jobs:
- name: Check for changes
id: check_changes
run: |
git diff --quiet docs/community-stats.md docs/community-stats.en.md README.md README_CN.md || echo "changed=true" >> $GITHUB_OUTPUT
git diff --quiet docs/community-stats.zh.md docs/community-stats.md README.md README_CN.md || echo "changed=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.check_changes.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add docs/community-stats.md docs/community-stats.en.md docs/community-stats.json README.md README_CN.md
git commit -m "📊 更新社区统计数据 $(date +'%Y-%m-%d')"
git add docs/community-stats.zh.md docs/community-stats.md docs/community-stats.json README.md README_CN.md
git commit -m "chore: update community stats $(date +'%Y-%m-%d')"
git push

28
.github/workflows/publish_plugin.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Publish Plugins to OpenWebUI Market
on:
release:
types: [published]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Publish Plugins
env:
OPENWEBUI_API_KEY: ${{ secrets.OPENWEBUI_API_KEY }}
run: python scripts/publish_plugin.py

View File

@@ -180,14 +180,34 @@ jobs:
- name: Determine version
id: version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}"
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
else
# Auto-generate version based on date and run number
VERSION="v$(date +'%Y.%m.%d')-${{ github.run_number }}"
# Auto-generate version based on date and daily release count
TODAY=$(date +'%Y.%m.%d')
TODAY_PREFIX="v${TODAY}-"
# Count existing releases with today's date prefix
# grep -c returns 1 if count is 0, so we use || true to avoid script failure
EXISTING_COUNT=$(gh release list --limit 100 2>/dev/null | grep -c "^${TODAY_PREFIX}" || true)
# Clean up output (handle potential newlines or fallback issues)
EXISTING_COUNT=$(echo "$EXISTING_COUNT" | tr -cd '0-9')
if [ -z "$EXISTING_COUNT" ]; then EXISTING_COUNT=0; fi
NEXT_NUM=$((EXISTING_COUNT + 1))
VERSION="${TODAY_PREFIX}${NEXT_NUM}"
# Final fallback to ensure VERSION is never empty
if [ -z "$VERSION" ]; then
VERSION="v$(date +'%Y.%m.%d-%H%M%S')"
fi
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Release version: $VERSION"
@@ -325,13 +345,34 @@ jobs:
echo "=== Release Notes ==="
cat release_notes.md
- name: Create Git Tag
run: |
VERSION="${{ steps.version.outputs.version }}"
if [ -z "$VERSION" ]; then
echo "Error: Version is empty!"
exit 1
fi
if ! git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Creating tag $VERSION"
git tag "$VERSION"
git push origin "$VERSION"
else
echo "Tag $VERSION already exists"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
target_commitish: ${{ github.sha }}
name: ${{ github.event.inputs.release_title || steps.version.outputs.version }}
body_path: release_notes.md
prerelease: ${{ github.event.inputs.prerelease || false }}
make_latest: true
files: |
plugin_versions.json
env:

View File

@@ -7,31 +7,26 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu
<!-- STATS_START -->
## 📊 Community Stats
> 🕐 Auto-updated: 2026-01-06 19:26 (Beijing Time)
> 🕐 Auto-updated: 2026-01-08 00:11
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **41** | **63** | **17** |
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **49** | **63** | **18** |
| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |
|:---:|:---:|:---:|:---:|:---:|
| **11** | **785** | **8394** | **54** | **46** |
| **11** | **785** | **8411** | **54** | **47** |
| **11** | **889** | **9358** | **55** | **48** |
### 🔥 Top 5 Popular Plugins
### 🔥 Top 6 Popular Plugins
| Rank | Plugin | Downloads | Views |
|:---:|------|:---:|:---:|
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 235 | 2095 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 170 | 455 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1234 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 75 | 1413 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 900 |
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 235 | 2103 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 170 | 456 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1235 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 75 | 1414 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 904 |
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 283 | 2441 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 175 | 486 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 118 | 1287 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 82 | 1528 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 80 | 1081 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 67 | 605 |
*See full stats in [Community Stats Report](./docs/community-stats.md)*
<!-- STATS_END -->

View File

@@ -7,27 +7,28 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词
<!-- STATS_START -->
## 📊 社区统计
> 🕐 自动更新于 2026-01-06 19:26 (北京时间)
> 🕐 自动更新于 2026-01-08 00:11
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **41** | **63** | **17** |
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **49** | **63** | **18** |
| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |
|:---:|:---:|:---:|:---:|:---:|
| **11** | **785** | **8394** | **54** | **46** |
| **11** | **889** | **9358** | **55** | **48** |
### 🔥 热门插件 Top 5
### 🔥 热门插件 Top 6
| 排名 | 插件 | 下载 | 浏览 |
|:---:|------|:---:|:---:|
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 235 | 2095 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 170 | 455 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1234 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 75 | 1413 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 900 |
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 283 | 2441 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 175 | 486 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 118 | 1287 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 82 | 1528 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 80 | 1081 |
| 6⃣ | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 67 | 605 |
*完整统计请查看 [社区统计报告](./docs/community-stats.md)*
*完整统计请查看 [社区统计报告](./docs/community-stats.zh.md)*
<!-- STATS_END -->
## 📦 项目内容

View File

@@ -1,35 +0,0 @@
# 📊 OpenWebUI Community Stats Report
> 📅 Updated: 2026-01-06 11:08:23
## 📈 Overview
| Metric | Value |
|------|------|
| 📝 Total Posts | 11 |
| ⬇️ Total Downloads | 785 |
| 👁️ Total Views | 8394 |
| 👍 Total Upvotes | 54 |
| 💾 Total Saves | 46 |
| 💬 Total Comments | 13 |
## 📂 By Type
- **action**: 9
- **filter**: 2
## 📋 Posts List
| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.8.2 | 235 | 2095 | 10 | 15 | 2026-01-03 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 170 | 455 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1234 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 75 | 1413 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 900 | 6 | 7 | 2026-01-03 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 499 | 5 | 4 | 2026-01-05 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 397 | 3 | 0 | 2025-12-29 |
| 8 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 246 | 2 | 0 | 2025-12-31 |
| 9 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 13 | 737 | 7 | 1 | 2026-01-05 |
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 309 | 3 | 1 | 2025-12-31 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 109 | 2 | 1 | 2025-12-31 |

View File

@@ -1,11 +1,11 @@
{
"total_posts": 11,
"total_downloads": 785,
"total_views": 8394,
"total_upvotes": 54,
"total_downvotes": 0,
"total_saves": 46,
"total_comments": 13,
"total_downloads": 889,
"total_views": 9358,
"total_upvotes": 55,
"total_downvotes": 1,
"total_saves": 48,
"total_comments": 15,
"by_type": {
"action": 9,
"filter": 2
@@ -15,16 +15,16 @@
"title": "Turn Any Text into Beautiful Mind Maps",
"slug": "turn_any_text_into_beautiful_mind_maps_3094c59a",
"type": "action",
"version": "0.8.2",
"version": "0.9.1",
"author": "Fu-Jie",
"description": "Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.",
"downloads": 235,
"views": 2095,
"downloads": 283,
"views": 2441,
"upvotes": 10,
"saves": 15,
"comments": 8,
"comments": 10,
"created_at": "2025-12-30",
"updated_at": "2026-01-03",
"updated_at": "2026-01-06",
"url": "https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a"
},
{
@@ -34,8 +34,8 @@
"version": "0.3.6",
"author": "Fu-Jie",
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
"downloads": 170,
"views": 455,
"downloads": 175,
"views": 486,
"upvotes": 3,
"saves": 3,
"comments": 0,
@@ -50,8 +50,8 @@
"version": "1.1.0",
"author": "Fu-Jie",
"description": "This filter automatically compresses long conversation contexts by intelligently summarizing and removing intermediate messages while preserving critical information, thereby significantly reducing token consumption.",
"downloads": 112,
"views": 1234,
"downloads": 118,
"views": 1287,
"upvotes": 5,
"saves": 9,
"comments": 0,
@@ -66,8 +66,8 @@
"version": "0.2.4",
"author": "Fu-Jie",
"description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
"downloads": 75,
"views": 1413,
"downloads": 82,
"views": 1528,
"upvotes": 8,
"saves": 5,
"comments": 2,
@@ -79,32 +79,32 @@
"title": "Smart Infographic",
"slug": "smart_infographic_ad6f0c7f",
"type": "action",
"version": "1.3.2",
"version": "1.4.0",
"author": "jeff",
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
"downloads": 65,
"views": 900,
"upvotes": 6,
"downloads": 80,
"views": 1081,
"upvotes": 7,
"saves": 8,
"comments": 2,
"created_at": "2025-12-28",
"updated_at": "2026-01-03",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
},
{
"title": "Export to Word (Enhanced Formatting)",
"slug": "export_to_word_enhanced_formatting_fca6a315",
"type": "action",
"version": "0.4.0",
"version": "0.4.2",
"author": "Fu-Jie",
"description": "Export the current conversation to a formatted Word doc with syntax highlighting, AI-generated titles, and perfect Markdown rendering (tables, quotes, lists).",
"downloads": 51,
"views": 499,
"downloads": 67,
"views": 605,
"upvotes": 5,
"saves": 4,
"comments": 0,
"created_at": "2026-01-03",
"updated_at": "2026-01-05",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315"
},
{
@@ -115,7 +115,7 @@
"author": "jeff",
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
"downloads": 33,
"views": 397,
"views": 426,
"upvotes": 3,
"saves": 0,
"comments": 0,
@@ -123,6 +123,22 @@
"updated_at": "2025-12-29",
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
},
{
"title": "导出为 Word-支持公式、流程图、表格和代码块",
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
"type": "action",
"version": "0.4.1",
"author": "Fu-Jie",
"description": "将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持中英文无乱码。",
"downloads": 20,
"views": 799,
"upvotes": 7,
"saves": 1,
"comments": 1,
"created_at": "2026-01-04",
"updated_at": "2026-01-05",
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
},
{
"title": "智能生成交互式思维导图,帮助用户可视化知识",
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
@@ -131,30 +147,14 @@
"author": "",
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
"downloads": 14,
"views": 246,
"views": 263,
"upvotes": 2,
"saves": 0,
"saves": 1,
"comments": 0,
"created_at": "2025-12-31",
"updated_at": "2025-12-31",
"url": "https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b"
},
{
"title": "导出为 Word-支持公式、流程图、表格和代码块",
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
"type": "action",
"version": "0.4.1",
"author": "Fu-Jie",
"description": "将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持中英文无乱码。",
"downloads": 13,
"views": 737,
"upvotes": 7,
"saves": 1,
"comments": 1,
"created_at": "2026-01-04",
"updated_at": "2026-01-05",
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
},
{
"title": "闪记卡生成插件",
"slug": "闪记卡生成插件_4a31eac3",
@@ -163,7 +163,7 @@
"author": "Fu-Jie",
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
"downloads": 12,
"views": 309,
"views": 320,
"upvotes": 3,
"saves": 1,
"comments": 0,
@@ -179,7 +179,7 @@
"author": "Fu-Jie",
"description": "在 LLM 响应完成后进行上下文摘要和压缩",
"downloads": 5,
"views": 109,
"views": 122,
"upvotes": 2,
"saves": 1,
"comments": 0,
@@ -193,11 +193,11 @@
"name": "Fu-Jie",
"profile_url": "https://openwebui.com/u/Fu-Jie",
"profile_image": "https://community.s3.openwebui.com/uploads/users/b15d1348-4347-42b4-b815-e053342d6cb0/profile_d9510745-4bd4-4f8f-a997-4a21847d9300.webp",
"followers": 41,
"followers": 49,
"following": 2,
"total_points": 63,
"post_points": 54,
"comment_points": 9,
"contributions": 17
"contributions": 18
}
}

View File

@@ -1,35 +1,35 @@
# 📊 OpenWebUI 社区统计报告
# 📊 OpenWebUI Community Stats Report
> 📅 更新时间: 2026-01-06 11:08:23
> 📅 Updated: 2026-01-08 00:11
## 📈 总览
## 📈 Overview
| 指标 | 数值 |
| Metric | Value |
|------|------|
| 📝 发布数量 | 11 |
| ⬇️ 总下载量 | 785 |
| 👁️ 总浏览量 | 8394 |
| 👍 总点赞数 | 54 |
| 💾 总收藏数 | 46 |
| 💬 总评论数 | 13 |
| 📝 Total Posts | 11 |
| ⬇️ Total Downloads | 889 |
| 👁️ Total Views | 9358 |
| 👍 Total Upvotes | 55 |
| 💾 Total Saves | 48 |
| 💬 Total Comments | 15 |
## 📂 按类型分类
## 📂 By Type
- **action**: 9
- **filter**: 2
## 📋 发布列表
## 📋 Posts List
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.8.2 | 235 | 2095 | 10 | 15 | 2026-01-03 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 170 | 455 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1234 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 75 | 1413 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 900 | 6 | 7 | 2026-01-03 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 499 | 5 | 4 | 2026-01-05 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 397 | 3 | 0 | 2025-12-29 |
| 8 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 246 | 2 | 0 | 2025-12-31 |
| 9 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 13 | 737 | 7 | 1 | 2026-01-05 |
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 309 | 3 | 1 | 2025-12-31 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 109 | 2 | 1 | 2025-12-31 |
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 283 | 2441 | 10 | 15 | 2026-01-06 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 175 | 486 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 118 | 1287 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 82 | 1528 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.0 | 80 | 1081 | 7 | 8 | 2026-01-07 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.2 | 67 | 605 | 5 | 4 | 2026-01-07 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 426 | 3 | 0 | 2025-12-29 |
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 20 | 799 | 7 | 1 | 2026-01-05 |
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 263 | 2 | 1 | 2025-12-31 |
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 320 | 3 | 1 | 2025-12-31 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 122 | 2 | 1 | 2025-12-31 |

View File

@@ -0,0 +1,35 @@
# 📊 OpenWebUI 社区统计报告
> 📅 更新时间: 2026-01-08 00:11
## 📈 总览
| 指标 | 数值 |
|------|------|
| 📝 发布数量 | 11 |
| ⬇️ 总下载量 | 889 |
| 👁️ 总浏览量 | 9358 |
| 👍 总点赞数 | 55 |
| 💾 总收藏数 | 48 |
| 💬 总评论数 | 15 |
## 📂 按类型分类
- **action**: 9
- **filter**: 2
## 📋 发布列表
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 283 | 2441 | 10 | 15 | 2026-01-06 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 175 | 486 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 118 | 1287 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 82 | 1528 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.0 | 80 | 1081 | 7 | 8 | 2026-01-07 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.2 | 67 | 605 | 5 | 4 | 2026-01-07 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 426 | 3 | 0 | 2025-12-29 |
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 20 | 799 | 7 | 1 | 2026-01-05 |
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 263 | 2 | 1 | 2025-12-31 |
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 320 | 3 | 1 | 2025-12-31 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 122 | 2 | 1 | 2025-12-31 |

View File

@@ -1,7 +1,7 @@
# Export to Word
<span class="category-badge action">Action</span>
<span class="version-badge">v0.4.1</span>
<span class="version-badge">v0.4.3</span>
Export conversation to Word (.docx) with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
@@ -53,6 +53,8 @@ You can configure the following settings via the **Valves** button in the plugin
| `MATH_ENABLE` | Enable LaTeX math block conversion. | `True` |
| `MATH_INLINE_DOLLAR_ENABLE` | Enable inline `$ ... $` math conversion. | `True` |
## 🔥 What's New in v0.4.3
### User-Level Configuration (UserValves)
Users can override the following settings in their personal settings:
@@ -118,3 +120,4 @@ Users can override the following settings in their personal settings:
## Source Code
[:fontawesome-brands-github: View on GitHub](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/export_to_docx){ .md-button }
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.4.3 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)

View File

@@ -1,7 +1,7 @@
# Export to Word导出为 Word
<span class="category-badge action">Action</span>
<span class="version-badge">v0.4.1</span>
<span class="version-badge">v0.4.3</span>
将当前对话导出为完美格式的 Word 文档,支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用资料**以及**增强表格**渲染。
@@ -117,4 +117,4 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
## 源码
[:fontawesome-brands-github: 在 GitHub 查看](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/export_to_docx){ .md-button }
[:fontawes**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.4.3 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)/tree/main/plugins/actions/export_to_docx){ .md-button }

View File

@@ -33,7 +33,7 @@ Actions are interactive plugins that:
Transform text into professional infographics using AntV visualization engine with various templates.
**Version:** 1.3.0
**Version:** 1.4.1
[:octicons-arrow-right-24: Documentation](smart-infographic.md)
@@ -63,7 +63,7 @@ Actions are interactive plugins that:
Export the current conversation to a formatted Word doc with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
**Version:** 0.4.1
**Version:** 0.4.2
[:octicons-arrow-right-24: Documentation](export-to-word.md)

View File

@@ -33,7 +33,7 @@ Actions 是交互式插件,能够:
使用 AntV 可视化引擎,将文本转成专业的信息图。
**版本:** 1.3.0
**版本:** 1.4.1
[:octicons-arrow-right-24: 查看文档](smart-infographic.md)
@@ -63,7 +63,7 @@ Actions 是交互式插件,能够:
将当前对话导出为完美格式的 Word 文档,支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用资料**以及**增强表格**渲染。
**版本:** 0.4.1
**版本:** 0.4.2
[:octicons-arrow-right-24: 查看文档](export-to-word.md)

View File

@@ -1,7 +1,7 @@
# Smart Infographic
<span class="category-badge action">Action</span>
<span class="version-badge">v1.3.0</span>
<span class="version-badge">v1.4.0</span>
An AntV Infographic engine powered plugin that transforms long text into professional, beautiful infographics with a single click.
@@ -19,6 +19,8 @@ The Smart Infographic plugin uses AI to analyze text content and generate profes
- :material-download: **Multi-Format Export**: Download your infographics as **SVG**, **PNG**, or **Standalone HTML** file
- :material-theme-light-dark: **Theme Support**: Supports Dark/Light modes, auto-adapts theme colors
- :material-cellphone-link: **Responsive Design**: Generated charts look great on both desktop and mobile devices
- :material-image: **Image Embedding**: Option to embed charts as static images for better compatibility
- :material-monitor-screenshot: **Adaptive Sizing**: Images automatically adapt to the chat container width
---
@@ -60,6 +62,7 @@ The Smart Infographic plugin uses AI to analyze text content and generate profes
| `MIN_TEXT_LENGTH` | integer | `100` | Minimum characters required to trigger analysis |
| `CLEAR_PREVIOUS_HTML` | boolean | `false` | Whether to clear previous charts |
| `MESSAGE_COUNT` | integer | `1` | Number of recent messages to use for analysis |
| `OUTPUT_MODE` | string | `image` | `image` for static image embedding (default), `html` for interactive chart |
---

View File

@@ -1,7 +1,7 @@
# Smart Infographic智能信息图
<span class="category-badge action">Action</span>
<span class="version-badge">v1.0.0</span>
<span class="version-badge">v1.4.0</span>
基于 AntV 信息图引擎,将长文本一键转成专业、美观的信息图。
@@ -19,6 +19,8 @@ Smart Infographic 使用 AI 分析文本,并基于 AntV 可视化引擎生成
- :material-download: **多格式导出**:支持下载 **SVG**、**PNG**、**独立 HTML**
- :material-theme-light-dark: **主题支持**:适配深色/浅色模式
- :material-cellphone-link: **响应式**:桌面与移动端都能良好展示
- :material-image: **图片嵌入**:支持将图表作为静态图片嵌入,兼容性更好
- :material-monitor-screenshot: **自适应尺寸**:图片模式下自动适应聊天容器宽度
---
@@ -60,6 +62,7 @@ Smart Infographic 使用 AI 分析文本,并基于 AntV 可视化引擎生成
| `MIN_TEXT_LENGTH` | integer | `100` | 触发分析的最小字符数 |
| `CLEAR_PREVIOUS_HTML` | boolean | `false` | 是否清空之前生成的图表 |
| `MESSAGE_COUNT` | integer | `1` | 参与分析的最近消息条数 |
| `OUTPUT_MODE` | string | `image` | `image` 为静态图片嵌入(默认),`html` 为交互式图表 |
---

View File

@@ -1,130 +1,88 @@
# Export to Word
# 📝 Export to Word (Enhanced)
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.4.3 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
Export conversation to Word (.docx) with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
## Features
## 🔥 What's New in v0.4.3
- **One-Click Export**: Adds an "Export to Word" action button to the chat.
- **Markdown Conversion**: Converts Markdown syntax to Word formatting (headings, bold, italic, code, tables, lists).
- **Syntax Highlighting**: Code blocks are highlighted with Pygments (supports 500+ languages).
- **Native Math Equations**: LaTeX math (`$$...$$`, `\[...\]`, `$...$`, `\(...\)`) converted to editable Word equations.
- **Mermaid Diagrams**: Mermaid flowcharts and sequence diagrams rendered as images in the document.
- **Citations & References**: Auto-generates a References section from OpenWebUI sources with clickable citation links.
- **Reasoning Stripping**: Automatically removes AI thinking blocks (`<think>`, `<analysis>`) from exports.
- **Enhanced Tables**: Smart column widths, column alignment (`:---`, `---:`, `:---:`), header row repeat across pages.
- **Blockquote Support**: Markdown blockquotes are rendered with left border and gray styling.
- **Multi-language Support**: Properly handles both Chinese and English text.
- **Smarter Filenames**: Configurable title source (Chat Title, AI Generated, or Markdown Title).
- **S3 Object Storage Support**: Direct access to images stored in S3/MinIO via boto3, bypassing API layer for faster exports.
- 🔧 **Multi-level File Fallback**: 6-level fallback mechanism for file retrieval (DB → S3 → Local → URL → API → Attributes).
- 🛡️ **Improved Error Handling**: Better logging and error messages for file retrieval failures.
## Configuration
## ✨ Key Features
You can configure the following settings via the **Valves** button in the plugin settings:
- 🚀 **One-Click Export**: Adds an "Export to Word" action button to the chat.
- 📄 **Markdown Conversion**: Full Markdown syntax support (headings, bold, italic, code, tables, lists).
- 🎨 **Syntax Highlighting**: Code blocks highlighted with Pygments (500+ languages).
- 🔢 **Native Math Equations**: LaTeX math (`$$...$$`, `\[...\]`, `$...$`) converted to editable Word equations.
- 📊 **Mermaid Diagrams**: Flowcharts and sequence diagrams rendered as images.
- 📚 **Citations & References**: Auto-generates References section with clickable citation links.
- 🧹 **Reasoning Stripping**: Automatically removes AI thinking blocks (`<think>`, `<analysis>`).
- 📋 **Enhanced Tables**: Smart column widths, alignment, header row repeat across pages.
- 💬 **Blockquote Support**: Markdown blockquotes with left border and gray styling.
- 🌐 **Multi-language Support**: Proper handling of Chinese and English text.
- **TITLE_SOURCE**: Choose how the document title/filename is generated.
- `chat_title`: Use the conversation title (default).
- `ai_generated`: Use AI to generate a short title based on the content.
- `markdown_title`: Extract the first h1/h2 heading from the Markdown content.
- **MAX_EMBED_IMAGE_MB**: Maximum image size to embed into DOCX (MB). Default: `20`.
- **UI_LANGUAGE**: User interface language, supports `en` (English) and `zh` (Chinese). Default: `en`.
- **FONT_LATIN**: Font name for Latin characters. Default: `Times New Roman`.
- **FONT_ASIAN**: Font name for Asian characters. Default: `SimSun`.
- **FONT_CODE**: Font name for code blocks. Default: `Consolas`.
- **TABLE_HEADER_COLOR**: Table header background color (Hex without #). Default: `F2F2F2`.
- **TABLE_ZEBRA_COLOR**: Table alternating row background color (Hex without #). Default: `FBFBFB`.
- **MERMAID_JS_URL**: URL for the Mermaid.js library.
- **MERMAID_JSZIP_URL**: URL for the JSZip library (required for DOCX manipulation).
- **MERMAID_PNG_SCALE**: Scale factor for Mermaid PNG generation (Resolution). Default: `3.0`.
- **MERMAID_DISPLAY_SCALE**: Scale factor for Mermaid visual size in Word. Default: `1.0`.
- **MERMAID_OPTIMIZE_LAYOUT**: Automatically convert LR (Left-Right) flowcharts to TD (Top-Down). Default: `False`.
- **MERMAID_BACKGROUND**: Background color for Mermaid diagrams (e.g., `white`, `transparent`). Default: `transparent`.
- **MERMAID_CAPTIONS_ENABLE**: Enable/disable figure captions for Mermaid diagrams. Default: `True`.
- **MERMAID_CAPTION_STYLE**: Paragraph style name for Mermaid captions. Default: `Caption`.
- **MERMAID_CAPTION_PREFIX**: Caption prefix label (e.g., 'Figure'). Empty = auto-detect based on language.
- **MATH_ENABLE**: Enable LaTeX math block conversion (`\[...\]` and `$$...$$`). Default: `True`.
- **MATH_INLINE_DOLLAR_ENABLE**: Enable inline `$ ... $` math conversion. Default: `True`.
## 🚀 How to Use
## Supported Markdown Syntax
1. **Install**: Search for "Export to Word" in the Open WebUI Community and install.
2. **Trigger**: In any chat, click the "Export to Word" action button.
3. **Download**: The .docx file will be automatically downloaded.
| Syntax | Word Result |
| :---------------------------------- | :------------------------------------ |
| `# Heading 1` to `###### Heading 6` | Heading levels 1-6 |
| `**bold**` or `__bold__` | Bold text |
| `*italic*` or `_italic_` | Italic text |
| `***bold italic***` | Bold + Italic |
| `` `inline code` `` | Monospace with gray background |
| ` ``` code block ``` ` | **Syntax highlighted** code block |
| `> blockquote` | Left-bordered gray italic text |
| `[link](url)` | Blue underlined link text |
| `~~strikethrough~~` | Strikethrough text |
| `- item` or `* item` | Bullet list |
| `1. item` | Numbered list |
| Markdown tables | **Enhanced table** with smart widths |
| `---` or `***` | Horizontal rule |
| `$$LaTeX$$` or `\[LaTeX\]` | **Native Word equation** (display) |
| `$LaTeX$` or `\(LaTeX\)` | **Native Word equation** (inline) |
| ` ```mermaid ... ``` ` | **Mermaid diagram** as image |
| `[1]` citation markers | **Clickable links** to References |
## ⚙️ Configuration (Valves)
## Usage
| Parameter | Default | Description |
| :--- | :--- | :--- |
| **Title Source (TITLE_SOURCE)** | `chat_title` | `chat_title`, `ai_generated`, or `markdown_title` |
| **Max Image Size (MAX_EMBED_IMAGE_MB)** | `20` | Maximum image size to embed (MB) |
| **UI Language (UI_LANGUAGE)** | `en` | `en` (English) or `zh` (Chinese) |
| **Latin Font (FONT_LATIN)** | `Times New Roman` | Font for Latin characters |
| **Asian Font (FONT_ASIAN)** | `SimSun` | Font for Asian characters |
| **Code Font (FONT_CODE)** | `Consolas` | Font for code blocks |
| **Table Header Color** | `F2F2F2` | Header background color (hex) |
| **Table Zebra Color** | `FBFBFB` | Alternating row color (hex) |
| **Mermaid PNG Scale** | `3.0` | Resolution multiplier for Mermaid images |
| **Math Enable** | `True` | Enable LaTeX math conversion |
1. Install the plugin.
2. In any chat, click the "Export to Word" button.
3. The .docx file will be automatically downloaded to your device.
## 🛠️ Supported Markdown Syntax
## Requirements
| Syntax | Word Result |
| :--- | :--- |
| `# Heading 1` to `###### Heading 6` | Heading levels 1-6 |
| `**bold**` or `__bold__` | Bold text |
| `*italic*` or `_italic_` | Italic text |
| `` `inline code` `` | Monospace with gray background |
| ` ``` code block ``` ` | **Syntax highlighted** code block |
| `> blockquote` | Left-bordered gray italic text |
| `[link](url)` | Blue underlined link |
| `~~strikethrough~~` | Strikethrough text |
| `- item` or `* item` | Bullet list |
| `1. item` | Numbered list |
| Markdown tables | **Enhanced table** with smart widths |
| `$$LaTeX$$` or `\[LaTeX\]` | **Native Word equation** (display) |
| `$LaTeX$` or `\(LaTeX\)` | **Native Word equation** (inline) |
| ` ```mermaid ... ``` ` | **Mermaid diagram** as image |
| `[1]` citation markers | **Clickable links** to References |
## 📦 Requirements
- `python-docx==1.1.2` - Word document generation
- `Pygments>=2.15.0` - Syntax highlighting
- `latex2mathml` - LaTeX to MathML conversion
- `mathml2omml` - MathML to Office Math (OMML) conversion
All dependencies are declared in the plugin docstring.
## 📝 Changelog
## Font Configuration
### v0.4.3
- **S3 Object Storage**: Direct S3/MinIO access via boto3 for faster image retrieval.
- **6-Level Fallback**: Robust file retrieval: DB → S3 → Local → URL → API → Attributes.
- **Better Logging**: Improved error messages for debugging file access issues.
- **English Text**: Times New Roman
- **Chinese Text**: SimSun (宋体) for body, SimHei (黑体) for headings
- **Code**: Consolas
## Changelog
### v0.4.1
- **Chinese Parameter Names**: Localized configuration names for Chinese version.
### v0.4.0
- **Multi-language Support**: Added UI language switching (English/Chinese) with localized messages.
- **Font & Style Configuration**: Customizable fonts for Latin/Asian text and code, plus table colors.
- **Mermaid Enhancements**:
- Hybrid client-side rendering (SVG+PNG) for better clarity and compatibility.
- Configurable background color, fixing issues in dark mode.
- Added error boundaries to prevent export failures on render errors.
- **Performance**: Real-time progress updates for large document exports.
- **Bug Fixes**:
- Fixed parsing errors in Markdown tables containing code blocks or links.
- Fixed parsing issues with underscores (`_`), asterisks (`*`), and tildes (`~`) used as long separators.
- Enhanced error handling for image embedding.
### v0.3.0
- **Mermaid Diagrams**: Native support for rendering Mermaid diagrams as images in Word.
- **Native Math**: Converts LaTeX equations to native Office MathML for editable equations.
- **Citations**: Automatic bibliography generation and citation linking.
- **Reasoning Removal**: Option to strip `<think>` blocks from the output.
- **Table Enhancements**: Improved table formatting with smart column widths.
### v0.2.0
- Added native math equation support (LaTeX → OMML)
- Added Mermaid diagram rendering
- Added citations and references section generation
- Added automatic reasoning block stripping
- Enhanced table formatting with smart column widths and alignment
### v0.1.1
- Initial release with basic Markdown to Word conversion
## Author
Fu-Jie
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## License
MIT License
- **Multi-language Support**: UI language switching (English/Chinese).
- **Font & Style Configuration**: Customizable fonts and table colors.
- **Mermaid Enhancements**: Hybrid SVG+PNG rendering, background color config.
- **Performance**: Real-time progress updates for large exports.

View File

@@ -1,134 +1,88 @@
# 导出为 Word
# 📝 导出为 Word (增强版)
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.4.3 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
将对话导出为 Word (.docx),支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用参考**和**增强表格格式**。
## 功能特点
## 🔥 v0.4.3 更新内容
- **一键导出**:在聊天界面添加"导出为 Word"动作按钮
- **Markdown 转换**:将 Markdown 语法转换为 Word 格式(标题、粗体、斜体、代码、表格、列表)。
- **代码语法高亮**:使用 Pygments 库为代码块添加语法高亮(支持 500+ 种语言)
- **原生数学公式**LaTeX 公式(`$$...$$``\[...\]``$...$``\(...\)`)转换为可编辑的 Word 公式。
- **Mermaid 图表**Mermaid 流程图和时序图渲染为文档中的图片。
- **引用与参考**:自动从 OpenWebUI 来源生成参考资料章节,支持可点击的引用链接。
- **移除思考过程**:自动移除 AI 思考块(`<think>``<analysis>`)。
- **增强表格**:智能列宽、列对齐(`:---``---:``:---:`)、表头跨页重复。
- **引用块支持**Markdown 引用块渲染为带左侧边框的灰色斜体样式。
- **多语言支持**:正确处理中文和英文文本,无乱码问题。
- **智能文件名**可配置标题来源对话标题、AI 生成或 Markdown 标题)。
- **S3 对象存储支持**: 通过 boto3 直连 S3/MinIO绕过 API 层,导出速度更快
- 🔧 **多级文件回退**: 6 级文件获取机制(数据库 → S3 → 本地 → URL → API → 属性)。
- 🛡️ **错误处理优化**: 更完善的日志记录和错误提示,便于调试文件访问问题
## 配置
## ✨ 核心特性
您可以通过插件设置中的 **Valves** 按钮配置以下选项:
- 🚀 **一键导出**: 在聊天界面添加"导出为 Word"动作按钮。
- 📄 **Markdown 转换**: 完整支持 Markdown 语法(标题、粗体、斜体、代码、表格、列表)。
- 🎨 **代码语法高亮**: 使用 Pygments 库高亮代码块(支持 500+ 种语言)。
- 🔢 **原生数学公式**: LaTeX 公式(`$$...$$``\[...\]``$...$`)转换为可编辑的 Word 公式。
- 📊 **Mermaid 图表**: 流程图和时序图渲染为文档中的图片。
- 📚 **引用与参考**: 自动生成参考资料章节,支持可点击的引用链接。
- 🧹 **移除思考过程**: 自动移除 AI 思考块(`<think>``<analysis>`)。
- 📋 **增强表格**: 智能列宽、对齐、表头跨页重复。
- 💬 **引用块支持**: Markdown 引用块渲染为带左侧边框的灰色斜体样式。
- 🌐 **多语言支持**: 正确处理中文和英文文本。
- **文档标题来源**:选择文档标题/文件名的生成方式。
- `chat_title`:使用对话标题(默认)。
- `ai_generated`:使用 AI 根据内容生成简短标题。
- `markdown_title`:从 Markdown 内容中提取第一个一级或二级标题。
- **最大嵌入图片大小MB**:嵌入图片的最大大小 (MB)。默认:`20`
- **界面语言**:界面语言,支持 `en` (英语) 和 `zh` (中文)。默认:`zh`
- **英文字体**:英文字体名称。默认:`Calibri`
- **中文字体**:中文字体名称。默认:`SimSun`
- **代码字体**:代码字体名称。默认:`Consolas`
- **表头背景色**:表头背景色(十六进制,不带#)。默认:`F2F2F2`
- **表格隔行背景色**:表格隔行背景色(十六进制,不带#)。默认:`FBFBFB`
- **Mermaid_JS地址**Mermaid.js 库的 URL。
- **JSZip库地址**JSZip 库的 URL用于 DOCX 操作)。
- **Mermaid_PNG缩放比例**Mermaid PNG 生成缩放比例(分辨率)。默认:`3.0`
- **Mermaid显示比例**Mermaid 在 Word 中的显示比例(视觉大小)。默认:`1.0`
- **Mermaid布局优化**:自动将 LR左右流程图转换为 TD上下。默认`False`
- **Mermaid背景色**Mermaid 图表背景色(如 `white`, `transparent`)。默认:`transparent`
- **启用Mermaid图注**:启用/禁用 Mermaid 图表的图注。默认:`True`
- **Mermaid图注样式**Mermaid 图注的段落样式名称。默认:`Caption`
- **Mermaid图注前缀**:图注前缀(如 '图')。留空则根据语言自动检测。
- **启用数学公式**:启用 LaTeX 数学公式块转换(`\[...\]``$$...$$`)。默认:`True`
- **启用行内公式**:启用行内 `$ ... $` 数学公式转换。默认:`True`
## 🚀 使用方法
## 支持的 Markdown 语法
1. **安装**: 在 Open WebUI 社区搜索 "导出为 Word" 并安装。
2. **触发**: 在任意对话中,点击"导出为 Word"动作按钮。
3. **下载**: .docx 文件将自动下载到你的设备。
| 语法 | Word 效果 |
| :---------------------------- | :-------------------------------- |
| `# 标题1``###### 标题6` | 标题级别 1-6 |
| `**粗体**``__粗体__` | 粗体文本 |
| `*斜体*``_斜体_` | 斜体文本 |
| `***粗斜体***` | 粗体 + 斜体 |
| `` `行内代码` `` | 等宽字体 + 灰色背景 |
| ` ``` 代码块 ``` ` | **语法高亮**的代码块 |
| `> 引用文本` | 带左侧边框的灰色斜体文本 |
| `[链接](url)` | 蓝色下划线链接文本 |
| `~~删除线~~` | 删除线文本 |
| `- 项目``* 项目` | 无序列表 |
| `1. 项目` | 有序列表 |
| Markdown 表格 | **增强表格**(智能列宽) |
| `---``***` | 水平分割线 |
| `$$LaTeX$$``\[LaTeX\]` | **原生 Word 公式**(块级) |
| `$LaTeX$``\(LaTeX\)` | **原生 Word 公式**(行内) |
| ` ```mermaid ... ``` ` | **Mermaid 图表**(图片形式) |
| `[1]` 引用标记 | **可点击链接**到参考资料 |
## ⚙️ 配置参数 (Valves)
## 使用方法
| 参数 | 默认值 | 说明 |
| :--- | :--- | :--- |
| **文档标题来源** | `chat_title` | `chat_title`(对话标题)、`ai_generated`AI 生成)、`markdown_title`Markdown 标题)|
| **最大嵌入图片大小MB** | `20` | 嵌入图片的最大大小 (MB) |
| **界面语言** | `zh` | `en`(英语)或 `zh`(中文)|
| **英文字体** | `Calibri` | 英文字体名称 |
| **中文字体** | `SimSun` | 中文字体名称 |
| **代码字体** | `Consolas` | 代码块字体名称 |
| **表头背景色** | `F2F2F2` | 表头背景色(十六进制)|
| **表格隔行背景色** | `FBFBFB` | 表格隔行背景色(十六进制)|
| **Mermaid_PNG缩放比例** | `3.0` | Mermaid 图片分辨率倍数 |
| **启用数学公式** | `True` | 启用 LaTeX 公式转换 |
1. 安装插件。
2. 在任意对话中,点击"导出为 Word"按钮。
3. .docx 文件将自动下载到你的设备。
## 🛠️ 支持的 Markdown 语法
## 依赖
| 语法 | Word 效果 |
| :--- | :--- |
| `# 标题1``###### 标题6` | 标题级别 1-6 |
| `**粗体**``__粗体__` | 粗体文本 |
| `*斜体*``_斜体_` | 斜体文本 |
| `` `行内代码` `` | 等宽字体 + 灰色背景 |
| ` ``` 代码块 ``` ` | **语法高亮**的代码块 |
| `> 引用文本` | 带左侧边框的灰色斜体文本 |
| `[链接](url)` | 蓝色下划线链接文本 |
| `~~删除线~~` | 删除线文本 |
| `- 项目``* 项目` | 无序列表 |
| `1. 项目` | 有序列表 |
| Markdown 表格 | **增强表格**(智能列宽)|
| `$$LaTeX$$``\[LaTeX\]` | **原生 Word 公式**(块级)|
| `$LaTeX$``\(LaTeX\)` | **原生 Word 公式**(行内)|
| ` ```mermaid ... ``` ` | **Mermaid 图表**(图片形式)|
| `[1]` 引用标记 | **可点击链接**到参考资料 |
## 📦 依赖
- `python-docx==1.1.2` - Word 文档生成
- `Pygments>=2.15.0` - 语法高亮
- `latex2mathml` - LaTeX 转 MathML
- `mathml2omml` - MathML 转 Office Math (OMML)
所有依赖已在插件文档字符串中声明。
## 📝 更新日志
## 字体配置
- **英文文本**Times New Roman
- **中文文本**:宋体(正文)、黑体(标题)
- **代码**Consolas
## 更新日志
### v0.4.3
- **S3 对象存储**: 通过 boto3 直连 S3/MinIO图片获取速度更快。
- **6 级回退机制**: 稳健的文件获取:数据库 → S3 → 本地 → URL → API → 属性。
- **日志优化**: 改进错误提示,便于调试文件访问问题。
### v0.4.1
- **中文参数名**: 将插件配置项名称和描述全部汉化,提升中文用户体验。
- **中文参数名**: 配置项名称和描述全部汉化。
### v0.4.0
- **多语言支持**: 新增界面语言切换(中文/英文),提示信息更友好。
- **多语言支持**: 界面语言切换(中文/英文)。
- **字体与样式配置**: 支持自定义中英文字体、代码字体以及表格颜色。
- **Mermaid 增强**:
- 客户端混合渲染SVG+PNG提高清晰度与兼容性。
- 支持背景色配置,修复深色模式下的显示问题。
- 增加错误边界,渲染失败时显示提示而非中断导出。
- **Mermaid 增强**: 混合 SVG+PNG 渲染,支持背景色配置。
- **性能优化**: 导出大型文档时提供实时进度反馈。
- **Bug 修复**:
- 修复 Markdown 表格中包含代码块或链接时的解析错误。
- 修复下划线(`_`)、星号(`*`)、波浪号(`~`)作为长分隔符时的解析问题。
- 增强图片嵌入的错误处理。
### v0.3.0
- **Mermaid 图表**: 原生支持将 Mermaid 图表渲染为 Word 中的图片。
- **原生公式**: 将 LaTeX 公式转换为原生 Office MathML支持在 Word 中编辑。
- **引用参考**: 自动生成参考文献列表并链接引用。
- **移除推理**: 选项支持从输出中移除 `<think>` 推理块。
- **表格增强**: 改进表格格式,支持智能列宽。
### v0.2.0
- 新增原生数学公式支持LaTeX → OMML
- 新增 Mermaid 图表渲染
- 新增引用与参考资料章节生成
- 新增自动移除 AI 思考块
- 增强表格格式(智能列宽、对齐)
### v0.1.1
- 初始版本,支持基本 Markdown 转 Word
## 作者
Fu-Jie
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## 许可证
MIT License

View File

@@ -3,7 +3,8 @@ title: Export to Word (Enhanced)
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.4.1
version: 0.4.3
openwebui_id: fca6a315-2a45-42cc-8c96-55cbc85f87f2
icon_url: data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB3aWR0aD0iMjQiCiAgaGVpZ2h0PSIyNCIKICB2aWV3Qm94PSIwIDAgMjQgMjQiCiAgZmlsbD0ibm9uZSIKICBzdHJva2U9ImN1cnJlbnRDb2xvciIKICBzdHJva2Utd2lkdGg9IjIiCiAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgogIHN0cm9rZS1saW5lam9pbj0icm91bmQiCj4KICA8cGF0aCBkPSJNNiAyMmEyIDIgMCAwIDEtMi0yVjRhMiAyIDAgMCAxIDItMmg4YTIuNCAyLjQgMCAwIDEgMS43MDQuNzA2bDMuNTg4IDMuNTg4QTIuNCAyLjQgMCAwIDEgMjAgOHYxMmEyIDIgMCAwIDEtMiAyeiIgLz4KICA8cGF0aCBkPSJNMTQgMnY1YTEgMSAwIDAgMCAxIDFoNSIgLz4KICA8cGF0aCBkPSJNMTAgOUg4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxM0g4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxN0g4IiAvPgo8L3N2Zz4K
requirements: python-docx, Pygments, latex2mathml, mathml2omml
description: Export current conversation from Markdown to Word (.docx) with Mermaid diagrams rendered client-side (Mermaid.js, SVG+PNG), LaTeX math, real hyperlinks, improved tables, syntax highlighting, and blockquote support.
@@ -65,6 +66,16 @@ try:
except Exception:
LATEX_MATH_AVAILABLE = False
# boto3 for S3 direct access (faster than API fallback)
try:
import boto3
from botocore.config import Config as BotoConfig
import os
BOTO3_AVAILABLE = True
except ImportError:
BOTO3_AVAILABLE = False
logging.basicConfig(
level=logging.INFO,
@@ -290,6 +301,8 @@ class Action:
self._bookmark_id_counter: int = 1
self._active_doc: Optional[Document] = None
self._user_lang: str = "en" # Will be set per-request
self._api_token: Optional[str] = None
self._api_base_url: Optional[str] = None
def _get_lang_key(self, user_language: str) -> str:
"""Convert user language code to i18n key (e.g., 'zh-CN' -> 'zh', 'en-US' -> 'en')."""
@@ -349,6 +362,22 @@ class Action:
# Get user language from Valves configuration
self._user_lang = self._get_lang_key(self.valves.UI_LANGUAGE)
# Extract API connection info for file fetching (S3/Object Storage support)
def _get_default_base_url() -> str:
port = os.environ.get("PORT") or "8080"
return f"http://localhost:{port}"
if __request__:
try:
self._api_token = __request__.headers.get("Authorization")
self._api_base_url = str(__request__.base_url).rstrip("/")
except Exception:
self._api_token = None
self._api_base_url = _get_default_base_url()
else:
self._api_token = None
self._api_base_url = _get_default_base_url()
if __event_emitter__:
last_assistant_message = body["messages"][-1]
@@ -1075,19 +1104,85 @@ class Action:
b64 = m.group("b64") or ""
return self._decode_base64_limited(b64, max_bytes)
def _read_from_s3(self, s3_path: str, max_bytes: int) -> Optional[bytes]:
"""Read file directly from S3 using environment variables for credentials."""
if not BOTO3_AVAILABLE:
return None
# Parse s3://bucket/key
if not s3_path.startswith("s3://"):
return None
path_without_prefix = s3_path[5:] # Remove 's3://'
parts = path_without_prefix.split("/", 1)
if len(parts) < 2:
return None
bucket = parts[0]
key = parts[1]
# Read S3 config from environment variables
endpoint_url = os.environ.get("S3_ENDPOINT_URL")
access_key = os.environ.get("S3_ACCESS_KEY_ID")
secret_key = os.environ.get("S3_SECRET_ACCESS_KEY")
addressing_style = os.environ.get("S3_ADDRESSING_STYLE", "auto")
if not all([endpoint_url, access_key, secret_key]):
logger.debug(
"S3 environment variables not fully configured, skipping S3 direct download."
)
return None
try:
s3_config = BotoConfig(
s3={"addressing_style": addressing_style},
connect_timeout=5,
read_timeout=15,
)
s3_client = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=s3_config,
)
response = s3_client.get_object(Bucket=bucket, Key=key)
body = response["Body"]
data = body.read(max_bytes + 1)
body.close()
if len(data) > max_bytes:
return None
return data
except Exception as e:
logger.warning(f"S3 direct download failed for {s3_path}: {e}")
return None
def _image_bytes_from_owui_file_id(
self, file_id: str, max_bytes: int
) -> Optional[bytes]:
if not file_id or Files is None:
return None
try:
file_obj = Files.get_file_by_id(file_id)
except Exception:
return None
if not file_obj:
if not file_id:
return None
# Common patterns across Open WebUI versions / storage backends.
if Files is None:
logger.error(
"Files model is not available (import failed). Cannot retrieve file content."
)
return None
try:
file_obj = Files.get_file_by_id(file_id)
except Exception as e:
logger.error(f"Files.get_file_by_id({file_id}) failed: {e}")
return None
if not file_obj:
logger.warning(f"File {file_id} not found in database.")
return None
# 1. Try data field (DB stored)
data_field = getattr(file_obj, "data", None)
if isinstance(data_field, dict):
blob_value = data_field.get("bytes")
@@ -1099,19 +1194,119 @@ class Action:
if isinstance(inline, str) and inline.strip():
return self._decode_base64_limited(inline, max_bytes)
# 2. Try S3 direct download (fastest for object storage)
s3_path = getattr(file_obj, "path", None)
if isinstance(s3_path, str) and s3_path.startswith("s3://"):
s3_data = self._read_from_s3(s3_path, max_bytes)
if s3_data is not None:
return s3_data
# 3. Try file paths (Disk stored)
# We try multiple path variations to be robust against CWD differences (e.g. Docker vs Local)
for attr in ("path", "file_path", "absolute_path"):
candidate = getattr(file_obj, attr, None)
if isinstance(candidate, str) and candidate.strip():
raw = self._read_file_bytes_limited(Path(candidate), max_bytes)
# Skip obviously non-local paths (S3, GCS, HTTP)
if re.match(r"^(s3://|gs://|https?://)", candidate, re.IGNORECASE):
logger.debug(f"Skipping local read for non-local path: {candidate}")
continue
p = Path(candidate)
# Attempt 1: As-is (Absolute or relative to CWD)
raw = self._read_file_bytes_limited(p, max_bytes)
if raw is not None:
return raw
# Attempt 2: Relative to ./data (Common in OpenWebUI)
if not p.is_absolute():
try:
raw = self._read_file_bytes_limited(
Path("./data") / p, max_bytes
)
if raw is not None:
return raw
except Exception:
pass
# Attempt 3: Relative to /app/backend/data (Docker default)
try:
raw = self._read_file_bytes_limited(
Path("/app/backend/data") / p, max_bytes
)
if raw is not None:
return raw
except Exception:
pass
# 4. Try URL (Object Storage / S3 Public URL)
urls_to_try = []
url_attr = getattr(file_obj, "url", None)
if isinstance(url_attr, str) and url_attr:
urls_to_try.append(url_attr)
if isinstance(data_field, dict):
url_data = data_field.get("url")
if isinstance(url_data, str) and url_data:
urls_to_try.append(url_data)
if urls_to_try:
import urllib.request
for url in urls_to_try:
if not url.startswith(("http://", "https://")):
continue
try:
logger.info(
f"Attempting to download file {file_id} from URL: {url}"
)
# Use a timeout to avoid hanging
req = urllib.request.Request(
url, headers={"User-Agent": "OpenWebUI-Export-Plugin"}
)
with urllib.request.urlopen(req, timeout=15) as response:
if 200 <= response.status < 300:
data = response.read(max_bytes + 1)
if len(data) <= max_bytes:
return data
else:
logger.warning(
f"File {file_id} from URL is too large (> {max_bytes} bytes)"
)
except Exception as e:
logger.warning(f"Failed to download {file_id} from {url}: {e}")
# 5. Try fetching via Local API (Last resort for S3/Object Storage without direct URL)
# If we have the API token and base URL, we can try to fetch the content through the backend API.
if self._api_base_url:
api_url = f"{self._api_base_url}/api/v1/files/{file_id}/content"
try:
import urllib.request
headers = {"User-Agent": "OpenWebUI-Export-Plugin"}
if self._api_token:
headers["Authorization"] = self._api_token
req = urllib.request.Request(api_url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as response:
if 200 <= response.status < 300:
data = response.read(max_bytes + 1)
if len(data) <= max_bytes:
return data
except Exception:
# API fetch failed, just fall through to the next method
pass
# 6. Try direct content attributes (last ditch)
for attr in ("content", "blob", "data"):
raw = getattr(file_obj, attr, None)
if isinstance(raw, (bytes, bytearray)):
b = bytes(raw)
return b if len(b) <= max_bytes else None
logger.warning(
f"File {file_id} found but no content accessible. Attributes: {dir(file_obj)}"
)
return None
def _add_image_placeholder(self, paragraph, alt: str, reason: str):

View File

@@ -3,7 +3,8 @@ title: 导出为 Word (增强版)
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.4.1
version: 0.4.3
openwebui_id: 8a6306c0-d005-4e46-aaae-8db3532c9ed5
icon_url: data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB3aWR0aD0iMjQiCiAgaGVpZ2h0PSIyNCIKICB2aWV3Qm94PSIwIDAgMjQgMjQiCiAgZmlsbD0ibm9uZSIKICBzdHJva2U9ImN1cnJlbnRDb2xvciIKICBzdHJva2Utd2lkdGg9IjIiCiAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgogIHN0cm9rZS1saW5lam9pbj0icm91bmQiCj4KICA8cGF0aCBkPSJNNiAyMmEyIDIgMCAwIDEtMi0yVjRhMiAyIDAgMCAxIDItMmg4YTIuNCAyLjQgMCAwIDEgMS43MDQuNzA2bDMuNTg4IDMuNTg4QTIuNCAyLjQgMCAwIDEgMjAgOHYxMmEyIDIgMCAwIDEtMiAyeiIgLz4KICA8cGF0aCBkPSJNMTQgMnY1YTEgMSAwIDAgMCAxIDFoNSIgLz4KICA8cGF0aCBkPSJNMTAgOUg4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxM0g4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxN0g4IiAvPgo8L3N2Zz4K
requirements: python-docx, Pygments, latex2mathml, mathml2omml
description: 将对话导出为 Word (.docx),支持 Mermaid 图表 (客户端渲染 SVG+PNG)、LaTeX 数学公式、真实超链接、增强表格格式、代码高亮和引用块。
@@ -65,6 +66,16 @@ try:
except Exception:
LATEX_MATH_AVAILABLE = False
# boto3 for S3 direct access (faster than API fallback)
try:
import boto3
from botocore.config import Config as BotoConfig
import os
BOTO3_AVAILABLE = True
except ImportError:
BOTO3_AVAILABLE = False
logging.basicConfig(
level=logging.INFO,
@@ -290,6 +301,8 @@ class Action:
self._bookmark_id_counter: int = 1
self._active_doc: Optional[Document] = None
self._user_lang: str = "en" # Will be set per-request
self._api_token: Optional[str] = None
self._api_base_url: Optional[str] = None
def _get_lang_key(self, user_language: str) -> str:
"""Convert user language code to i18n key (e.g., 'zh-CN' -> 'zh', 'en-US' -> 'en')."""
@@ -347,6 +360,22 @@ class Action:
# Get user language from Valves configuration
self._user_lang = self._get_lang_key(self.valves.界面语言)
# Extract API connection info for file fetching (S3/Object Storage support)
def _get_default_base_url() -> str:
port = os.environ.get("PORT") or "8080"
return f"http://localhost:{port}"
if __request__:
try:
self._api_token = __request__.headers.get("Authorization")
self._api_base_url = str(__request__.base_url).rstrip("/")
except Exception:
self._api_token = None
self._api_base_url = _get_default_base_url()
else:
self._api_token = None
self._api_base_url = _get_default_base_url()
if __event_emitter__:
last_assistant_message = body["messages"][-1]
@@ -1073,19 +1102,85 @@ class Action:
b64 = m.group("b64") or ""
return self._decode_base64_limited(b64, max_bytes)
def _read_from_s3(self, s3_path: str, max_bytes: int) -> Optional[bytes]:
"""Read file directly from S3 using environment variables for credentials."""
if not BOTO3_AVAILABLE:
return None
# Parse s3://bucket/key
if not s3_path.startswith("s3://"):
return None
path_without_prefix = s3_path[5:] # Remove 's3://'
parts = path_without_prefix.split("/", 1)
if len(parts) < 2:
return None
bucket = parts[0]
key = parts[1]
# Read S3 config from environment variables
endpoint_url = os.environ.get("S3_ENDPOINT_URL")
access_key = os.environ.get("S3_ACCESS_KEY_ID")
secret_key = os.environ.get("S3_SECRET_ACCESS_KEY")
addressing_style = os.environ.get("S3_ADDRESSING_STYLE", "auto")
if not all([endpoint_url, access_key, secret_key]):
logger.debug(
"S3 environment variables not fully configured, skipping S3 direct download."
)
return None
try:
s3_config = BotoConfig(
s3={"addressing_style": addressing_style},
connect_timeout=5,
read_timeout=15,
)
s3_client = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=s3_config,
)
response = s3_client.get_object(Bucket=bucket, Key=key)
body = response["Body"]
data = body.read(max_bytes + 1)
body.close()
if len(data) > max_bytes:
return None
return data
except Exception as e:
logger.warning(f"S3 direct download failed for {s3_path}: {e}")
return None
def _image_bytes_from_owui_file_id(
self, file_id: str, max_bytes: int
) -> Optional[bytes]:
if not file_id or Files is None:
return None
try:
file_obj = Files.get_file_by_id(file_id)
except Exception:
return None
if not file_obj:
if not file_id:
return None
# Common patterns across Open WebUI versions / storage backends.
if Files is None:
logger.error(
"Files model is not available (import failed). Cannot retrieve file content."
)
return None
try:
file_obj = Files.get_file_by_id(file_id)
except Exception as e:
logger.error(f"Files.get_file_by_id({file_id}) failed: {e}")
return None
if not file_obj:
logger.warning(f"File {file_id} not found in database.")
return None
# 1. Try data field (DB stored)
data_field = getattr(file_obj, "data", None)
if isinstance(data_field, dict):
blob_value = data_field.get("bytes")
@@ -1097,19 +1192,119 @@ class Action:
if isinstance(inline, str) and inline.strip():
return self._decode_base64_limited(inline, max_bytes)
# 2. Try S3 direct download (fastest for object storage)
s3_path = getattr(file_obj, "path", None)
if isinstance(s3_path, str) and s3_path.startswith("s3://"):
s3_data = self._read_from_s3(s3_path, max_bytes)
if s3_data is not None:
return s3_data
# 3. Try file paths (Disk stored)
# We try multiple path variations to be robust against CWD differences (e.g. Docker vs Local)
for attr in ("path", "file_path", "absolute_path"):
candidate = getattr(file_obj, attr, None)
if isinstance(candidate, str) and candidate.strip():
raw = self._read_file_bytes_limited(Path(candidate), max_bytes)
# Skip obviously non-local paths (S3, GCS, HTTP)
if re.match(r"^(s3://|gs://|https?://)", candidate, re.IGNORECASE):
logger.debug(f"Skipping local read for non-local path: {candidate}")
continue
p = Path(candidate)
# Attempt 1: As-is (Absolute or relative to CWD)
raw = self._read_file_bytes_limited(p, max_bytes)
if raw is not None:
return raw
# Attempt 2: Relative to ./data (Common in OpenWebUI)
if not p.is_absolute():
try:
raw = self._read_file_bytes_limited(
Path("./data") / p, max_bytes
)
if raw is not None:
return raw
except Exception:
pass
# Attempt 3: Relative to /app/backend/data (Docker default)
try:
raw = self._read_file_bytes_limited(
Path("/app/backend/data") / p, max_bytes
)
if raw is not None:
return raw
except Exception:
pass
# 4. Try URL (Object Storage / S3 Public URL)
urls_to_try = []
url_attr = getattr(file_obj, "url", None)
if isinstance(url_attr, str) and url_attr:
urls_to_try.append(url_attr)
if isinstance(data_field, dict):
url_data = data_field.get("url")
if isinstance(url_data, str) and url_data:
urls_to_try.append(url_data)
if urls_to_try:
import urllib.request
for url in urls_to_try:
if not url.startswith(("http://", "https://")):
continue
try:
logger.info(
f"Attempting to download file {file_id} from URL: {url}"
)
# Use a timeout to avoid hanging
req = urllib.request.Request(
url, headers={"User-Agent": "OpenWebUI-Export-Plugin"}
)
with urllib.request.urlopen(req, timeout=15) as response:
if 200 <= response.status < 300:
data = response.read(max_bytes + 1)
if len(data) <= max_bytes:
return data
else:
logger.warning(
f"File {file_id} from URL is too large (> {max_bytes} bytes)"
)
except Exception as e:
logger.warning(f"Failed to download {file_id} from {url}: {e}")
# 5. Try fetching via Local API (Last resort for S3/Object Storage without direct URL)
# If we have the API token and base URL, we can try to fetch the content through the backend API.
if self._api_base_url:
api_url = f"{self._api_base_url}/api/v1/files/{file_id}/content"
try:
import urllib.request
headers = {"User-Agent": "OpenWebUI-Export-Plugin"}
if self._api_token:
headers["Authorization"] = self._api_token
req = urllib.request.Request(api_url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as response:
if 200 <= response.status < 300:
data = response.read(max_bytes + 1)
if len(data) <= max_bytes:
return data
except Exception:
# API fetch failed, just fall through to the next method
pass
# 6. Try direct content attributes (last ditch)
for attr in ("content", "blob", "data"):
raw = getattr(file_obj, attr, None)
if isinstance(raw, (bytes, bytearray)):
b = bytes(raw)
return b if len(b) <= max_bytes else None
logger.warning(
f"File {file_id} found but no content accessible. Attributes: {dir(file_obj)}"
)
return None
def _add_image_placeholder(self, paragraph, alt: str, reason: str):

View File

@@ -4,6 +4,7 @@ author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.3.7
openwebui_id: 244b8f9d-7459-47d6-84d3-c7ae8e3ec710
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAySDZhMiAyIDAgMCAwLTIgMnYxNmEyIDIgMCAwIDAgMiAyaDEyYTIgMiAwIDAgMCAyLTJWN1oiLz48cGF0aCBkPSJNMTQgMnY0YTIgMiAwIDAgMCAyIDJoNCIvPjxwYXRoIGQ9Ik04IDEzaDIiLz48cGF0aCBkPSJNMTQgMTNoMiIvPjxwYXRoIGQ9Ik04IDE3aDIiLz48cGF0aCBkPSJNMTQgMTdoMiIvPjwvc3ZnPg==
description: Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.
"""

View File

@@ -4,6 +4,7 @@ author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.2.4
openwebui_id: 65a2ea8f-2a13-4587-9d76-55eea0035cc8
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5Z29uIHBvaW50cz0iMTIgMiAyIDcgMTIgMTIgMjIgNyAxMiAyIi8+PHBvbHlsaW5lIHBvaW50cz0iMiAxNyAxMiAyMiAyMiAxNyIvPjxwb2x5bGluZSBwb2ludHM9IjIgMTIgMTIgMTcgMjIgMTIiLz48L3N2Zz4=
description: Quickly generates beautiful flashcards from text, extracting key points and categories.
"""

View File

@@ -4,6 +4,7 @@ author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.2.4
openwebui_id: 4a31eac3-a3c4-4c30-9ca5-dab36b5fac65
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5Z29uIHBvaW50cz0iMTIgMiAyIDcgMTIgMTIgMjIgNyAxMiAyIi8+PHBvbHlsaW5lIHBvaW50cz0iMiAxNyAxMiAyMiAyMiAxNyIvPjxwb2x5bGluZSBwb2ludHM9IjIgMTIgMTIgMTcgMjIgMTIiLz48L3N2Zz4=
description: 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。
"""

View File

@@ -1,7 +1,19 @@
# 📊 Smart Infographic (AntV)
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.4.1 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
An Open WebUI plugin powered by the AntV Infographic engine. It transforms long text into professional, beautiful infographics with a single click.
## 🔥 What's New in v1.4.1
-**PNG Upload**: Infographics now upload as PNG format for better Word export compatibility.
- 🔧 **Canvas Conversion**: Uses browser canvas for high-quality SVG to PNG conversion (2x scale).
### Previous: v1.4.0
-**Default Mode Change**: Default output mode is now `image` (static image) for better compatibility.
- 📱 **Responsive Sizing**: Images now auto-adapt to the chat container width.
## ✨ Key Features
- 🚀 **AI-Powered Transformation**: Automatically analyzes text logic, extracts key points, and generates structured charts.
@@ -11,15 +23,6 @@ An Open WebUI plugin powered by the AntV Infographic engine. It transforms long
- 🌈 **Highly Customizable**: Supports Dark/Light modes, auto-adapts theme colors, with bold titles and refined card layouts.
- 📱 **Responsive Design**: Generated charts look great on both desktop and mobile devices.
## 🛠️ Supported Template Types
| Category | Template Name | Use Case |
| :--- | :--- | :--- |
| **Lists & Hierarchy** | `list-grid`, `tree-vertical`, `mindmap` | Features, Org Charts, Brainstorming |
| **Sequence & Relation** | `sequence-roadmap`, `relation-circle` | Roadmaps, Circular Flows, Steps |
| **Comparison & Analysis** | `compare-binary`, `compare-swot`, `quadrant-quarter` | Pros/Cons, SWOT, Quadrants |
| **Charts & Data** | `chart-bar`, `chart-line`, `chart-pie` | Trends, Distributions, Metrics |
## 🚀 How to Use
1. **Install**: Search for "Smart Infographic" in the Open WebUI Community and install.
@@ -38,6 +41,16 @@ You can adjust the following parameters in the plugin settings to optimize the g
| **Min Text Length (MIN_TEXT_LENGTH)** | `100` | Minimum characters required to trigger analysis, preventing accidental triggers on short text. |
| **Clear Previous (CLEAR_PREVIOUS_HTML)** | `False` | Whether to clear previous charts. If `False`, new charts will be appended below. |
| **Message Count (MESSAGE_COUNT)** | `1` | Number of recent messages to use for analysis. Increase this for more context. |
| **Output Mode (OUTPUT_MODE)** | `image` | `image` for static image embedding (default, better compatibility), `html` for interactive chart. |
## 🛠️ Supported Template Types
| Category | Template Name | Use Case |
| :--- | :--- | :--- |
| **Lists & Hierarchy** | `list-grid`, `tree-vertical`, `mindmap` | Features, Org Charts, Brainstorming |
| **Sequence & Relation** | `sequence-roadmap`, `relation-circle` | Roadmaps, Circular Flows, Steps |
| **Comparison & Analysis** | `compare-binary`, `compare-swot`, `quadrant-quarter` | Pros/Cons, SWOT, Quadrants |
| **Charts & Data** | `chart-bar`, `chart-line`, `chart-pie` | Trends, Distributions, Metrics |
## 📝 Syntax Example (For Advanced Users)
@@ -54,18 +67,3 @@ data
- label Beautiful Design
desc Uses AntV professional design standards
```
## 👨‍💻 Author
**jeff**
- GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## 📄 License
MIT License
## Changelog
### v1.3.2
- Removed debug messages from output

View File

@@ -1,7 +1,19 @@
# 📊 智能信息图 (AntV Infographic)
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.4.1 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
基于 AntV Infographic 引擎的 Open WebUI 插件,能够将长文本内容一键转换为专业、美观的信息图表。
## 🔥 v1.4.1 更新日志
-**PNG 上传**:信息图现在以 PNG 格式上传,与 Word 导出完美兼容。
- 🔧 **Canvas 转换**:使用浏览器 Canvas 高质量转换 SVG 为 PNG2倍缩放
### 此前: v1.4.0
-**默认模式变更**:默认输出模式调整为 `image`(静态图片)。
- 📱 **响应式尺寸**:图片模式下自动适应聊天容器宽度。
## ✨ 核心特性
- 🚀 **智能转换**:自动分析文本核心逻辑,提取关键点并生成结构化图表。
@@ -11,15 +23,6 @@
- 🌈 **高度自定义**:支持深色/浅色模式,自动适配主题颜色,主标题加粗突出,卡片布局精美。
- 📱 **响应式设计**:生成的图表在桌面端和移动端均有良好的展示效果。
## 🛠️ 支持的模板类型
| 分类 | 模板名称 | 适用场景 |
| :--- | :--- | :--- |
| **列表与层级** | `list-grid`, `tree-vertical`, `mindmap` | 功能亮点、组织架构、思维导图 |
| **顺序与关系** | `sequence-roadmap`, `relation-circle` | 发展历程、循环关系、步骤说明 |
| **对比与分析** | `compare-binary`, `compare-swot`, `quadrant-quarter` | 优劣势对比、SWOT 分析、象限图 |
| **图表与数据** | `chart-bar`, `chart-line`, `chart-pie` | 数据趋势、比例分布、数值对比 |
## 🚀 使用方法
1. **安装插件**:在 Open WebUI 插件市场搜索并安装。
@@ -38,6 +41,16 @@
| **最小文本长度 (MIN_TEXT_LENGTH)** | `100` | 触发分析所需的最小字符数,防止对过短的对话误操作。 |
| **清除旧结果 (CLEAR_PREVIOUS_HTML)** | `False` | 每次生成是否清除之前的图表。若为 `False`,新图表将追加在下方。 |
| **上下文消息数 (MESSAGE_COUNT)** | `1` | 用于分析的最近消息条数。增加此值可让 AI 参考更多对话背景。 |
| **输出模式 (OUTPUT_MODE)** | `image` | `image` 为静态图片嵌入(默认,兼容性好),`html` 为交互式图表。 |
## 🛠️ 支持的模板类型
| 分类 | 模板名称 | 适用场景 |
| :--- | :--- | :--- |
| **列表与层级** | `list-grid`, `tree-vertical`, `mindmap` | 功能亮点、组织架构、思维导图 |
| **顺序与关系** | `sequence-roadmap`, `relation-circle` | 发展历程、循环关系、步骤说明 |
| **对比与分析** | `compare-binary`, `compare-swot`, `quadrant-quarter` | 优劣势对比、SWOT 分析、象限图 |
| **图表与数据** | `chart-bar`, `chart-line`, `chart-pie` | 数据趋势、比例分布、数值对比 |
## 📝 语法示例 (高级用户)
@@ -54,18 +67,3 @@ data
- label 视觉精美
desc 采用 AntV 专业设计规范
```
## 👨‍💻 作者
**jeff**
- GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## 📄 许可证
MIT License
## 更新日志
### v1.3.2
- 移除输出中的调试信息

View File

@@ -3,12 +3,13 @@ title: 📊 Smart Infographic (AntV)
author: jeff
author_url: https://github.com/Fu-Jie/awesome-openwebui
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
version: 1.3.2
version: 1.4.1
openwebui_id: ad6f0c7f-c571-4dea-821d-8e71697274cf
description: AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Callable, Awaitable
import logging
import time
import re
@@ -821,10 +822,54 @@ class Action:
default=1,
description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
)
OUTPUT_MODE: str = Field(
default="image",
description="Output mode: 'html' for interactive HTML, or 'image' to embed as Markdown image (default).",
)
def __init__(self):
self.valves = self.Valves()
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
"""Extract chat_id from body or metadata"""
if isinstance(body, dict):
chat_id = body.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
chat_id = body_metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
if isinstance(metadata, dict):
chat_id = metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
return ""
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
"""Extract message_id from body or metadata"""
if isinstance(body, dict):
message_id = body.get("id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
message_id = body_metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
if isinstance(metadata, dict):
message_id = metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
return ""
def _extract_infographic_syntax(self, llm_output: str) -> str:
"""Extract infographic syntax from LLM output"""
match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL)
@@ -912,14 +957,359 @@ class Action:
return base_html.strip()
def _generate_image_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
infographic_syntax: str,
) -> str:
"""Generate JavaScript code for frontend SVG rendering and image embedding"""
# Escape the syntax for JS embedding
syntax_escaped = (
infographic_syntax.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const defaultWidth = 1100;
const defaultHeight = 500;
// Auto-detect chat container width for responsive sizing
let svgWidth = defaultWidth;
let svgHeight = defaultHeight;
const chatContainer = document.getElementById('chat-container');
if (chatContainer) {{
const containerWidth = chatContainer.clientWidth;
if (containerWidth > 100) {{
// Use container width with padding (80% of container, leaving more space on the right)
svgWidth = Math.floor(containerWidth * 0.8);
// Maintain aspect ratio based on default dimensions
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
console.log("[Infographic Image] Auto-detected container width:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
}}
}}
console.log("[Infographic Image] Starting render...");
console.log("[Infographic Image] chatId:", chatId, "messageId:", messageId);
try {{
// Load AntV Infographic if not loaded
if (typeof AntVInfographic === 'undefined') {{
console.log("[Infographic Image] Loading AntV Infographic...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
const {{ Infographic }} = AntVInfographic;
// Get syntax content
let syntaxContent = `{syntax_escaped}`;
console.log("[Infographic Image] Syntax length:", syntaxContent.length);
// Clean up syntax: remove code block markers
const backtick = String.fromCharCode(96);
const prefix = backtick + backtick + backtick + 'infographic';
const simplePrefix = backtick + backtick + backtick;
if (syntaxContent.toLowerCase().startsWith(prefix)) {{
syntaxContent = syntaxContent.substring(prefix.length).trim();
}} else if (syntaxContent.startsWith(simplePrefix)) {{
syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
}}
if (syntaxContent.endsWith(simplePrefix)) {{
syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
}}
// Fix syntax: remove colons after keywords
syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
// Ensure infographic prefix
if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
const firstWord = syntaxContent.trim().split(/\\s+/)[0].toLowerCase();
if (!['data', 'theme', 'design', 'items'].includes(firstWord)) {{
syntaxContent = 'infographic ' + syntaxContent;
}}
}}
// Template mapping
const TEMPLATE_MAPPING = {{
'list-grid': 'list-grid-compact-card',
'list-vertical': 'list-column-simple-vertical-arrow',
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
'relation-sankey': 'relation-sankey-simple',
'relation-circle': 'relation-circle-icon-badge',
'compare-binary': 'compare-binary-horizontal-simple-vs',
'compare-swot': 'compare-swot',
'quadrant-quarter': 'quadrant-quarter-simple-card',
'statistic-card': 'list-grid-compact-card',
'chart-bar': 'chart-bar-plain-text',
'chart-column': 'chart-column-simple',
'chart-line': 'chart-line-plain-text',
'chart-area': 'chart-area-simple',
'chart-pie': 'chart-pie-plain-text',
'chart-doughnut': 'chart-pie-donut-plain-text'
}};
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
if (regex.test(syntaxContent)) {{
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
break;
}}
}}
// Create offscreen container
const container = document.createElement('div');
container.id = 'infographic-offscreen-' + uniqueId;
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;background:#ffffff;';
document.body.appendChild(container);
// Create infographic instance
const instance = new Infographic({{
container: '#' + container.id,
width: svgWidth,
height: svgHeight,
padding: 12,
}});
console.log("[Infographic Image] Rendering infographic...");
instance.render(syntaxContent);
// Wait for render to complete
await new Promise(resolve => setTimeout(resolve, 2000));
// Get SVG element
const svgEl = container.querySelector('svg');
if (!svgEl) {{
throw new Error('SVG element not found after rendering');
}}
// Get actual dimensions
const bbox = svgEl.getBoundingClientRect();
const width = bbox.width || svgWidth;
const height = bbox.height || svgHeight;
// Clone and prepare SVG for export
const clonedSvg = svgEl.cloneNode(true);
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
clonedSvg.setAttribute('width', width);
clonedSvg.setAttribute('height', height);
// Add background rect
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', '#ffffff');
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
// Serialize SVG to string
const svgData = new XMLSerializer().serializeToString(clonedSvg);
// Cleanup container
document.body.removeChild(container);
// Convert SVG to PNG using canvas for better compatibility
console.log("[Infographic Image] Converting SVG to PNG...");
const pngBlob = await new Promise((resolve, reject) => {{
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const scale = 2; // Higher resolution for clarity
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
// Fill white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
const img = new Image();
img.onload = () => {{
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {{
if (blob) {{
resolve(blob);
}} else {{
reject(new Error('Canvas toBlob failed'));
}}
}}, 'image/png');
}};
img.onerror = (e) => reject(new Error('Failed to load SVG as image: ' + e));
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
}});
const file = new File([pngBlob], `infographic-${{uniqueId}}.png`, {{ type: 'image/png' }});
// Upload file to OpenWebUI API
console.log("[Infographic Image] Uploading PNG file...");
const token = localStorage.getItem("token");
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/files/', {{
method: 'POST',
headers: {{
'Authorization': `Bearer ${{token}}`
}},
body: formData
}});
if (!uploadResponse.ok) {{
throw new Error(`Upload failed: ${{uploadResponse.statusText}}`);
}}
const fileData = await uploadResponse.json();
const fileId = fileData.id;
const imageUrl = `/api/v1/files/${{fileId}}/content`;
console.log("[Infographic Image] PNG file uploaded, ID:", fileId);
// Generate markdown image with file URL
const markdownImage = `![📊 Infographic](${{imageUrl}})`;
// Update message via API
if (chatId && messageId) {{
// Helper function with retry logic
const fetchWithRetry = async (url, options, retries = 3) => {{
for (let i = 0; i < retries; i++) {{
try {{
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {{
console.log(`[Infographic Image] Retry ${{i + 1}}/${{retries}} for ${{url}}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}} catch (e) {{
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}}
return null;
}};
// Get current chat data
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
method: "GET",
headers: {{ "Authorization": `Bearer ${{token}}` }}
}});
if (!getResponse.ok) {{
throw new Error("Failed to get chat data: " + getResponse.status);
}}
const chatData = await getResponse.json();
let updatedMessages = [];
let newContent = "";
if (chatData.chat && chatData.chat.messages) {{
updatedMessages = chatData.chat.messages.map(m => {{
if (m.id === messageId) {{
const originalContent = m.content || "";
// Remove existing infographic images
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
let cleanedContent = originalContent.replace(infographicPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// Append new image
newContent = cleanedContent + "\\n\\n" + markdownImage;
// Update history object as well
if (chatData.chat.history && chatData.chat.history.messages) {{
if (chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
}}
}}
return {{ ...m, content: newContent }};
}}
return m;
}});
}}
if (!newContent) {{
console.warn("[Infographic Image] Could not find message to update");
return;
}}
// Try to update frontend display via event API
try {{
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
}} catch (eventErr) {{
console.log("[Infographic Image] Event API not available, continuing...");
}}
// Persist to database
const updatePayload = {{
chat: {{
...chatData.chat,
messages: updatedMessages
}}
}};
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify(updatePayload)
}});
if (persistResponse && persistResponse.ok) {{
console.log("[Infographic Image] ✅ Message persisted successfully!");
}} else {{
console.error("[Infographic Image] ❌ Failed to persist message after retries");
}}
}} else {{
console.warn("[Infographic Image] ⚠️ Missing chatId or messageId, cannot persist");
}}
}} catch (error) {{
console.error("[Infographic Image] Error:", error);
}}
}})();
"""
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Any] = None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: Infographic started (v1.0.0)")
logger.info("Action: Infographic started (v1.4.0)")
# Get user information
if isinstance(__user__, (list, tuple)):
@@ -1114,6 +1504,45 @@ class Action:
user_language,
)
# Check output mode
if self.valves.OUTPUT_MODE == "image":
# Image mode: use JavaScript to render and embed as Markdown image
chat_id = self._extract_chat_id(body, body.get("metadata"))
message_id = self._extract_message_id(body, body.get("metadata"))
await self._emit_status(
__event_emitter__,
"📊 Infographic: Rendering image...",
False,
)
if __event_call__:
js_code = self._generate_image_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
infographic_syntax=infographic_syntax,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(
__event_emitter__, "✅ Infographic: Image generated!", True
)
await self._emit_notification(
__event_emitter__,
f"📊 Infographic image generated, {user_name}!",
"success",
)
logger.info("Infographic generation completed in image mode")
return body
# HTML mode (default): embed as HTML block
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"

View File

@@ -3,12 +3,13 @@ title: 📊 智能信息图 (AntV Infographic)
author: jeff
author_url: https://github.com/Fu-Jie/awesome-openwebui
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
version: 1.3.2
version: 1.4.1
openwebui_id: e04a48ff-23ee-4a41-8ea7-66c19524e7c8
description: 基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Callable, Awaitable
import logging
import time
import re
@@ -849,6 +850,10 @@ class Action:
default=1,
description="用于生成的最近消息数量。设置为1仅使用最后一条消息更大值可包含更多上下文。",
)
OUTPUT_MODE: str = Field(
default="image",
description="输出模式:'html' 为交互式HTML'image' 将嵌入为Markdown图片默认",
)
def __init__(self):
self.valves = self.Valves()
@@ -862,6 +867,46 @@ class Action:
"Sunday": "星期日",
}
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
"""从 body 或 metadata 中提取 chat_id"""
if isinstance(body, dict):
chat_id = body.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
chat_id = body_metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
if isinstance(metadata, dict):
chat_id = metadata.get("chat_id")
if isinstance(chat_id, str) and chat_id.strip():
return chat_id.strip()
return ""
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
"""从 body 或 metadata 中提取 message_id"""
if isinstance(body, dict):
message_id = body.get("id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
body_metadata = body.get("metadata", {})
if isinstance(body_metadata, dict):
message_id = body_metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
if isinstance(metadata, dict):
message_id = metadata.get("message_id")
if isinstance(message_id, str) and message_id.strip():
return message_id.strip()
return ""
def _extract_infographic_syntax(self, llm_output: str) -> str:
"""提取LLM输出中的infographic语法"""
# 1. 优先匹配 ```infographic
@@ -973,14 +1018,359 @@ class Action:
return base_html.strip()
def _generate_image_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
infographic_syntax: str,
) -> str:
"""生成前端 SVG 渲染和图片嵌入的 JavaScript 代码"""
# 转义语法以便在 JS 中嵌入
syntax_escaped = (
infographic_syntax.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const defaultWidth = 1100;
const defaultHeight = 500;
// 自动检测聊天容器宽度以实现响应式尺寸
let svgWidth = defaultWidth;
let svgHeight = defaultHeight;
const chatContainer = document.getElementById('chat-container');
if (chatContainer) {{
const containerWidth = chatContainer.clientWidth;
if (containerWidth > 100) {{
// 使用容器宽度的 80%(右边留更多空间)
svgWidth = Math.floor(containerWidth * 0.8);
// 根据默认尺寸保持宽高比
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
console.log("[Infographic Image] 自动检测容器宽度:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
}}
}}
console.log("[Infographic Image] 开始渲染...");
console.log("[Infographic Image] chatId:", chatId, "messageId:", messageId);
try {{
// 加载 AntV Infographic如果未加载
if (typeof AntVInfographic === 'undefined') {{
console.log("[Infographic Image] 加载 AntV Infographic...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://registry.npmmirror.com/@antv/infographic/0.2.1/files/dist/infographic.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
const {{ Infographic }} = AntVInfographic;
// 获取语法内容
let syntaxContent = `{syntax_escaped}`;
console.log("[Infographic Image] 语法长度:", syntaxContent.length);
// 清理语法:移除代码块标记
const backtick = String.fromCharCode(96);
const prefix = backtick + backtick + backtick + 'infographic';
const simplePrefix = backtick + backtick + backtick;
if (syntaxContent.toLowerCase().startsWith(prefix)) {{
syntaxContent = syntaxContent.substring(prefix.length).trim();
}} else if (syntaxContent.startsWith(simplePrefix)) {{
syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
}}
if (syntaxContent.endsWith(simplePrefix)) {{
syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
}}
// 修复语法:移除关键字后的冒号
syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
// 确保 infographic 前缀
if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
const firstWord = syntaxContent.trim().split(/\\s+/)[0].toLowerCase();
if (!['data', 'theme', 'design', 'items'].includes(firstWord)) {{
syntaxContent = 'infographic ' + syntaxContent;
}}
}}
// 模板映射
const TEMPLATE_MAPPING = {{
'list-grid': 'list-grid-compact-card',
'list-vertical': 'list-column-simple-vertical-arrow',
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
'relation-sankey': 'relation-sankey-simple',
'relation-circle': 'relation-circle-icon-badge',
'compare-binary': 'compare-binary-horizontal-simple-vs',
'compare-swot': 'compare-swot',
'quadrant-quarter': 'quadrant-quarter-simple-card',
'statistic-card': 'list-grid-compact-card',
'chart-bar': 'chart-bar-plain-text',
'chart-column': 'chart-column-simple',
'chart-line': 'chart-line-plain-text',
'chart-area': 'chart-area-simple',
'chart-pie': 'chart-pie-plain-text',
'chart-doughnut': 'chart-pie-donut-plain-text'
}};
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
if (regex.test(syntaxContent)) {{
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
break;
}}
}}
// 创建离屏容器
const container = document.createElement('div');
container.id = 'infographic-offscreen-' + uniqueId;
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;background:#ffffff;';
document.body.appendChild(container);
// 创建信息图实例
const instance = new Infographic({{
container: '#' + container.id,
width: svgWidth,
height: svgHeight,
padding: 12,
}});
console.log("[Infographic Image] 渲染信息图...");
instance.render(syntaxContent);
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 2000));
// 获取 SVG 元素
const svgEl = container.querySelector('svg');
if (!svgEl) {{
throw new Error('渲染后未找到 SVG 元素');
}}
// 获取实际尺寸
const bbox = svgEl.getBoundingClientRect();
const width = bbox.width || svgWidth;
const height = bbox.height || svgHeight;
// 克隆并准备导出的 SVG
const clonedSvg = svgEl.cloneNode(true);
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
clonedSvg.setAttribute('width', width);
clonedSvg.setAttribute('height', height);
// 添加背景矩形
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', '#ffffff');
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
// 序列化 SVG 为字符串
const svgData = new XMLSerializer().serializeToString(clonedSvg);
// 清理容器
document.body.removeChild(container);
// 使用 canvas 将 SVG 转换为 PNG 以提高兼容性
console.log("[Infographic Image] 正在将 SVG 转换为 PNG...");
const pngBlob = await new Promise((resolve, reject) => {{
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const scale = 2; // 更高分辨率以提高清晰度
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
// 填充白色背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
const img = new Image();
img.onload = () => {{
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {{
if (blob) {{
resolve(blob);
}} else {{
reject(new Error('Canvas toBlob 失败'));
}}
}}, 'image/png');
}};
img.onerror = (e) => reject(new Error('加载 SVG 图片失败: ' + e));
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
}});
const file = new File([pngBlob], `infographic-${{uniqueId}}.png`, {{ type: 'image/png' }});
// 上传文件到 OpenWebUI API
console.log("[Infographic Image] 上传 PNG 文件...");
const token = localStorage.getItem("token");
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/files/', {{
method: 'POST',
headers: {{
'Authorization': `Bearer ${{token}}`
}},
body: formData
}});
if (!uploadResponse.ok) {{
throw new Error(`上传失败: ${{uploadResponse.statusText}}`);
}}
const fileData = await uploadResponse.json();
const fileId = fileData.id;
const imageUrl = `/api/v1/files/${{fileId}}/content`;
console.log("[Infographic Image] PNG 文件已上传, ID:", fileId);
// 生成带文件 URL 的 markdown 图片
const markdownImage = `![📊 信息图](${{imageUrl}})`;
// 通过 API 更新消息
if (chatId && messageId) {{
// 带重试逻辑的辅助函数
const fetchWithRetry = async (url, options, retries = 3) => {{
for (let i = 0; i < retries; i++) {{
try {{
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {{
console.log(`[Infographic Image] 重试 ${{i + 1}}/${{retries}} for ${{url}}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}} catch (e) {{
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}}
return null;
}};
// 获取当前聊天数据
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
method: "GET",
headers: {{ "Authorization": `Bearer ${{token}}` }}
}});
if (!getResponse.ok) {{
throw new Error("获取聊天数据失败: " + getResponse.status);
}}
const chatData = await getResponse.json();
let updatedMessages = [];
let newContent = "";
if (chatData.chat && chatData.chat.messages) {{
updatedMessages = chatData.chat.messages.map(m => {{
if (m.id === messageId) {{
const originalContent = m.content || "";
// 移除已有的信息图图片
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
let cleanedContent = originalContent.replace(infographicPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// 追加新图片
newContent = cleanedContent + "\\n\\n" + markdownImage;
// 同时更新 history 对象
if (chatData.chat.history && chatData.chat.history.messages) {{
if (chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
}}
}}
return {{ ...m, content: newContent }};
}}
return m;
}});
}}
if (!newContent) {{
console.warn("[Infographic Image] 找不到要更新的消息");
return;
}}
// 尝试通过事件 API 更新前端显示
try {{
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
}} catch (eventErr) {{
console.log("[Infographic Image] 事件 API 不可用,继续...");
}}
// 持久化到数据库
const updatePayload = {{
chat: {{
...chatData.chat,
messages: updatedMessages
}}
}};
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify(updatePayload)
}});
if (persistResponse && persistResponse.ok) {{
console.log("[Infographic Image] ✅ 消息持久化成功!");
}} else {{
console.error("[Infographic Image] ❌ 重试后消息持久化失败");
}}
}} else {{
console.warn("[Infographic Image] ⚠️ 缺少 chatId 或 messageId无法持久化");
}}
}} catch (error) {{
console.error("[Infographic Image] 错误:", error);
}}
}})();
"""
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Any] = None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: 信息图启动 (v1.0.0)")
logger.info("Action: 信息图启动 (v1.4.0)")
# 获取用户信息
if isinstance(__user__, (list, tuple)):
@@ -1169,6 +1559,45 @@ class Action:
user_language,
)
# 检查输出模式
if self.valves.OUTPUT_MODE == "image":
# 图片模式:使用 JavaScript 渲染并嵌入为 Markdown 图片
chat_id = self._extract_chat_id(body, body.get("metadata"))
message_id = self._extract_message_id(body, body.get("metadata"))
await self._emit_status(
__event_emitter__,
"📊 信息图: 正在渲染图片...",
False,
)
if __event_call__:
js_code = self._generate_image_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
infographic_syntax=infographic_syntax,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(
__event_emitter__, "✅ 信息图: 图片生成完成!", True
)
await self._emit_notification(
__event_emitter__,
f"📊 信息图图片已生成,{user_name}",
"success",
)
logger.info("信息图生成完成(图片模式)")
return body
# HTML 模式(默认):嵌入为 HTML 块
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"

View File

@@ -1,6 +1,6 @@
# Smart Mind Map - Mind Mapping Generation Plugin
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.8.2 | **License:** MIT
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.9.1 | **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.
@@ -8,6 +8,25 @@ Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes
---
## 🔥 What's New in v0.9.1
**New Feature: Image Output Mode**
- **Static Image Support**: Added `OUTPUT_MODE` configuration parameter.
- `html` (default): Interactive HTML mind map.
- `image`: Static SVG image embedded directly in Markdown (**No HTML code output**, cleaner chat history).
- **Efficient Storage**: Image mode uploads SVG to `/api/v1/files`, avoiding huge base64 strings in chat history.
- **Smart Features**: Auto-responsive width and automatic theme detection (light/dark) for generated images.
| Feature | HTML Mode (Default) | Image Mode |
| :--- | :--- | :--- |
| **Output Format** | Interactive HTML Block | Static Markdown Image |
| **Interactivity** | Zoom, Pan, Expand/Collapse | None (Static Image) |
| **Chat History** | Contains HTML Code | Clean (Image URL only) |
| **Storage** | Browser Rendering | `/api/v1/files` Upload |
---
## Core Features
-**Intelligent Text Analysis**: Automatically identifies core themes, key concepts, and hierarchical structures
@@ -20,6 +39,7 @@ Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes
-**Real-time Rendering**: Renders mind maps directly in the chat interface without navigation
-**Export Capabilities**: Supports PNG, SVG code, and Markdown source export
-**Customizable Configuration**: Configurable LLM model, minimum text length, and other parameters
-**Image Output Mode**: Generate static SVG images embedded directly in Markdown (**No HTML code output**, cleaner chat history)
---
@@ -80,6 +100,7 @@ You can adjust the following parameters in the plugin's settings (Valves):
| `MIN_TEXT_LENGTH` | `100` | Minimum text length (in characters) required for mind map analysis. Text that's too short cannot generate valid mind maps. |
| `CLEAR_PREVIOUS_HTML` | `false` | Whether to clear previous plugin-generated HTML content when generating a new mind map. |
| `MESSAGE_COUNT` | `1` | Number of recent messages to use for mind map generation (1-5). |
| `OUTPUT_MODE` | `html` | Output mode: `html` for interactive HTML (default), or `image` to embed as static Markdown image. |
---
@@ -277,6 +298,32 @@ This plugin uses only OpenWebUI's built-in dependencies. **No additional package
## Changelog
### v0.9.1
**New Feature: Image Output Mode**
- Added `OUTPUT_MODE` configuration parameter with two options:
- `html` (default): Interactive HTML mind map with full control panel
- `image`: Static SVG image embedded directly in Markdown (uploaded to `/api/v1/files`)
- Image mode features:
- Auto-responsive width (adapts to chat container)
- Automatic theme detection (light/dark)
- Persistent storage via Chat API (survives page refresh)
- Efficient file storage (no huge base64 strings in chat history)
**Improvements:**
- Implemented robust Chat API update mechanism with retry logic
- Fixed message persistence using both `messages[]` and `history.messages`
- Added Event API for immediate frontend updates
- Removed unnecessary `SVG_WIDTH` and `SVG_HEIGHT` parameters (now auto-calculated)
**Technical Details:**
- Image mode uses `__event_call__` to execute JavaScript in the browser
- SVG is rendered offline, converted to Blob, and uploaded to OpenWebUI Files API
- Updates chat message with `/api/v1/files/{id}/content` URL via OpenWebUI Backend-Controlled API flow
### v0.8.2
- Removed debug messages from output

View File

@@ -1,6 +1,6 @@
# 思维导图 - 思维导图生成插件
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.8.2 | **许可证:** MIT
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.9.1 | **许可证:** MIT
> **重要提示**:为了确保所有插件的可维护性和易用性,每个插件都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
@@ -8,6 +8,25 @@
---
## 🔥 v0.9.1 更新亮点
**新功能:图片输出模式**
- **静态图片支持**:新增 `OUTPUT_MODE` 配置参数。
- `html`(默认):交互式 HTML 思维导图。
- `image`:静态 SVG 图片直接嵌入 Markdown**不输出 HTML 代码**,聊天记录更简洁)。
- **高效存储**:图片模式将 SVG 上传至 `/api/v1/files`,避免聊天记录中出现超长 Base64 字符串。
- **智能特性**:生成的图片支持自动响应式宽度和自动主题检测(亮色/暗色)。
| 特性 | HTML 模式 (默认) | 图片模式 |
| :--- | :--- | :--- |
| **输出格式** | 交互式 HTML 代码块 | 静态 Markdown 图片 |
| **交互性** | 缩放、拖拽、展开/折叠 | 无 (静态图片) |
| **聊天记录** | 包含 HTML 代码 | 简洁 (仅图片链接) |
| **存储方式** | 浏览器实时渲染 | `/api/v1/files` 上传 |
---
## 核心特性
-**智能文本分析**:自动识别文本的核心主题、关键概念和层次结构
@@ -20,6 +39,7 @@
-**实时渲染**:在聊天界面中直接渲染思维导图,无需跳转
-**导出功能**:支持 PNG、SVG 代码和 Markdown 源码导出
-**自定义配置**:可配置 LLM 模型、最小文本长度等参数
-**图片输出模式**:生成静态 SVG 图片直接嵌入 Markdown**不输出 HTML 代码**,聊天记录更简洁)
---
@@ -80,6 +100,7 @@
| `MIN_TEXT_LENGTH` | `100` | 进行思维导图分析所需的最小文本长度(字符数)。文本过短将无法生成有效的导图。 |
| `CLEAR_PREVIOUS_HTML` | `false` | 在生成新的思维导图时,是否清除之前由插件生成的 HTML 内容。 |
| `MESSAGE_COUNT` | `1` | 用于生成思维导图的最近消息数量1-5。 |
| `OUTPUT_MODE` | `html` | 输出模式:`html` 为交互式 HTML默认`image` 为嵌入静态 Markdown 图片。 |
---
@@ -277,6 +298,32 @@
## 更新日志
### v0.9.1
**新功能:图片输出模式**
- 新增 `OUTPUT_MODE` 配置参数,支持两种模式:
- `html`(默认):交互式 HTML 思维导图,带完整控制面板
- `image`:静态 SVG 图片直接嵌入 Markdown上传至 `/api/v1/files`
- 图片模式特性:
- 自动响应式宽度(适应聊天容器)
- 自动主题检测(亮色/暗色)
- 通过 Chat API 持久化存储(刷新页面后保留)
- 高效文件存储(聊天记录中无超长 Base64 字符串)
**改进项:**
- 实现健壮的 Chat API 更新机制,带重试逻辑
- 修复消息持久化,同时更新 `messages[]``history.messages`
- 添加 Event API 实现即时前端更新
- 移除不必要的 `SVG_WIDTH``SVG_HEIGHT` 参数(现已自动计算)
**技术细节:**
- 图片模式使用 `__event_call__` 在浏览器中执行 JavaScript
- SVG 离屏渲染,转换为 Blob并上传至 OpenWebUI Files API
- 通过 OpenWebUI Backend-Controlled API 流程更新聊天消息为 `/api/v1/files/{id}/content` URL
### v0.8.2
- 移除输出中的调试信息

View File

@@ -3,7 +3,8 @@ title: Smart Mind Map
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.9.0
version: 0.9.1
openwebui_id: 3094c59a-b4dd-4e0c-9449-15e2dd547dc4
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
description: Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.
"""
@@ -790,14 +791,6 @@ class Action:
default="html",
description="Output mode: 'html' for interactive HTML (default), or 'image' to embed as Markdown image.",
)
SVG_WIDTH: int = Field(
default=1200,
description="Width of the SVG canvas in pixels (for image mode).",
)
SVG_HEIGHT: int = Field(
default=800,
description="Height of the SVG canvas in pixels (for image mode).",
)
def __init__(self):
self.valves = self.Valves()
@@ -959,27 +952,83 @@ class Action:
chat_id: str,
message_id: str,
markdown_syntax: str,
svg_width: int,
svg_height: int,
) -> str:
"""Generate JavaScript code for frontend SVG rendering and image embedding"""
# Escape the syntax for JS embedding
syntax_escaped = (
markdown_syntax
.replace("\\", "\\\\")
markdown_syntax.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const defaultWidth = {svg_width};
const defaultHeight = {svg_height};
const defaultWidth = 1200;
const defaultHeight = 800;
// Theme detection - check parent document for OpenWebUI theme
const detectTheme = () => {{
try {{
// 1. Check parent document's html/body class or data-theme
const html = document.documentElement;
const body = document.body;
const htmlClass = html ? html.className : '';
const bodyClass = body ? body.className : '';
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
return 'dark';
}}
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
return 'light';
}}
// 2. Check meta theme-color
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (metas.length > 0) {{
const color = metas[metas.length - 1].content.trim();
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
if (m) {{
const hex = m[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luma < 0.5 ? 'dark' : 'light';
}}
}}
// 3. Check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
return 'dark';
}}
return 'light';
}} catch (e) {{
return 'light';
}}
}};
const currentTheme = detectTheme();
console.log("[MindMap Image] Detected theme:", currentTheme);
// Theme-based colors
const colors = currentTheme === 'dark' ? {{
background: '#1f2937',
text: '#e5e7eb',
link: '#94a3b8',
nodeStroke: '#64748b'
}} : {{
background: '#ffffff',
text: '#1f2937',
link: '#546e7a',
nodeStroke: '#94a3b8'
}};
// Auto-detect chat container width for responsive sizing
let svgWidth = defaultWidth;
@@ -1054,7 +1103,7 @@ class Action:
svgEl.setAttribute('height', svgHeight);
svgEl.style.width = svgWidth + 'px';
svgEl.style.height = svgHeight + 'px';
svgEl.style.backgroundColor = '#ffffff';
svgEl.style.backgroundColor = colors.background;
container.appendChild(svgEl);
// Transform markdown to tree
@@ -1082,23 +1131,23 @@ class Action:
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
// Add background rect
// Add background rect with theme color
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', '#ffffff');
bgRect.setAttribute('fill', colors.background);
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
// Add inline styles
// Add inline styles with theme colors
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
text {{ font-family: sans-serif; font-size: 14px; fill: #000000; }}
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: #000000; font-family: sans-serif; font-size: 14px; }}
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
strong {{ font-weight: 700; }}
.markmap-link {{ stroke: #546e7a; fill: none; }}
.markmap-node circle, .markmap-node rect {{ stroke: #94a3b8; }}
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
`;
clonedSvg.insertBefore(style, bgRect.nextSibling);
@@ -1110,7 +1159,7 @@ class Action:
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('x', fo.getAttribute('x') || '0');
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
textEl.setAttribute('fill', '#000000');
textEl.setAttribute('fill', colors.text);
textEl.setAttribute('font-family', 'sans-serif');
textEl.setAttribute('font-size', '14');
textEl.textContent = text.trim();
@@ -1120,20 +1169,61 @@ class Action:
// Serialize SVG to string
const svgData = new XMLSerializer().serializeToString(clonedSvg);
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUrl = 'data:image/svg+xml;base64,' + svgBase64;
console.log("[MindMap Image] Data URL generated, length:", dataUrl.length);
// Cleanup
// Cleanup container
document.body.removeChild(container);
// Generate markdown image
const markdownImage = `![🧠 Mind Map](${{dataUrl}})`;
// Convert SVG string to Blob
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
// Upload file to OpenWebUI API
console.log("[MindMap Image] Uploading SVG file...");
const token = localStorage.getItem("token");
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/files/', {{
method: 'POST',
headers: {{
'Authorization': `Bearer ${{token}}`
}},
body: formData
}});
if (!uploadResponse.ok) {{
throw new Error(`Upload failed: ${{uploadResponse.statusText}}`);
}}
const fileData = await uploadResponse.json();
const fileId = fileData.id;
const imageUrl = `/api/v1/files/${{fileId}}/content`;
console.log("[MindMap Image] File uploaded, ID:", fileId);
// Generate markdown image with file URL
const markdownImage = `![🧠 Mind Map](${{imageUrl}})`;
// Update message via API
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// Helper function with retry logic
const fetchWithRetry = async (url, options, retries = 3) => {{
for (let i = 0; i < retries; i++) {{
try {{
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {{
console.log(`[MindMap Image] Retry ${{i + 1}}/${{retries}} for ${{url}}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}} catch (e) {{
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}}
return null;
}};
// Get current chat data
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
@@ -1146,24 +1236,26 @@ class Action:
}}
const chatData = await getResponse.json();
let originalContent = "";
let updatedMessages = [];
let newContent = "";
if (chatData.chat && chatData.chat.messages) {{
updatedMessages = chatData.chat.messages.map(m => {{
if (m.id === messageId) {{
originalContent = m.content || "";
// Remove existing mindmap images
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
const originalContent = m.content || "";
// Remove existing mindmap images (both base64 and file URL patterns)
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
let cleanedContent = originalContent.replace(mindmapPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// Append new image
const newContent = cleanedContent + "\\n\\n" + markdownImage;
newContent = cleanedContent + "\\n\\n" + markdownImage;
// Critical: Update content in both messages array AND history object
// The history object is often the source of truth for the database
if (chatData.chat.history && chatData.chat.history.messages && chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
// The history object is the source of truth for the database
if (chatData.chat.history && chatData.chat.history.messages) {{
if (chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
}}
}}
return {{ ...m, content: newContent }};
@@ -1172,28 +1264,40 @@ class Action:
}});
}}
// First: Update frontend display via event API (for immediate visual feedback)
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: updatedMessages.find(m => m.id === messageId)?.content || "" }}
}})
}});
if (!newContent) {{
console.warn("[MindMap Image] Could not find message to update");
return;
}}
// Second: Persist to database by updating the entire chat
// Try to update frontend display via event API (optional, may not exist in all versions)
try {{
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
}} catch (eventErr) {{
// Event API is optional, continue with persistence
console.log("[MindMap Image] Event API not available, continuing...");
}}
// Persist to database by updating the entire chat object
// This follows the OpenWebUI Backend-Controlled API Flow
const updatePayload = {{
chat: {{
...chatData.chat,
messages: updatedMessages
// history is already updated in-place above
}}
}};
const persistResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
@@ -1202,22 +1306,13 @@ class Action:
body: JSON.stringify(updatePayload)
}});
if (persistResponse.ok) {{
if (persistResponse && persistResponse.ok) {{
console.log("[MindMap Image] ✅ Message persisted successfully!");
}} else {{
console.error("[MindMap Image] Persist API error:", persistResponse.status);
// Try alternative update method
const altResponse = await fetch(`/api/v1/chats/${{chatId}}/share`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}}
}});
console.log("[MindMap Image] Alt persist attempted:", altResponse.status);
console.error("[MindMap Image] ❌ Failed to persist message after retries");
}}
}} else {{
console.warn("[MindMap Image] ⚠️ Missing chatId or messageId");
console.warn("[MindMap Image] ⚠️ Missing chatId or messageId, cannot persist");
}}
}} catch (error) {{
@@ -1235,7 +1330,7 @@ class Action:
__metadata__: Optional[dict] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: Smart Mind Map (v0.8.0) started")
logger.info("Action: Smart Mind Map (v0.9.1) started")
user_ctx = self._get_user_context(__user__)
user_language = user_ctx["user_language"]
user_name = user_ctx["user_name"]
@@ -1422,30 +1517,28 @@ class Action:
# Image mode: use JavaScript to render and embed as Markdown image
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
await self._emit_status(
__event_emitter__,
"Smart Mind Map: Rendering image...",
False,
)
if __event_call__:
js_code = self._generate_image_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
markdown_syntax=markdown_syntax,
svg_width=self.valves.SVG_WIDTH,
svg_height=self.valves.SVG_HEIGHT,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(
__event_emitter__, "Smart Mind Map: Image generated!", True
)
@@ -1454,9 +1547,9 @@ class Action:
f"Mind map image has been generated, {user_name}!",
"success",
)
logger.info("Action: Smart Mind Map (v0.9.0) completed in image mode")
logger.info("Action: Smart Mind Map (v0.9.1) completed in image mode")
return body
# HTML mode (default): embed as HTML block
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
@@ -1469,7 +1562,7 @@ class Action:
f"Mind map has been generated, {user_name}!",
"success",
)
logger.info("Action: Smart Mind Map (v0.9.0) completed in HTML mode")
logger.info("Action: Smart Mind Map (v0.9.1) completed in HTML mode")
except Exception as e:
error_message = f"Smart Mind Map processing failed: {str(e)}"

View File

@@ -3,7 +3,8 @@ title: 思维导图
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.9.0
version: 0.9.1
openwebui_id: 8d4b097b-219b-4dd2-b509-05fbe6388335
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
"""
@@ -789,14 +790,6 @@ class Action:
default="html",
description="输出模式: 'html' 为交互式HTML(默认),'image' 为嵌入Markdown图片。",
)
SVG_WIDTH: int = Field(
default=1200,
description="SVG画布宽度(像素,用于图片模式)。",
)
SVG_HEIGHT: int = Field(
default=800,
description="SVG画布高度(像素,用于图片模式)。",
)
def __init__(self):
self.valves = self.Valves()
@@ -870,9 +863,7 @@ class Action:
if match:
extracted_content = match.group(1).strip()
else:
logger.warning(
"LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。"
)
logger.warning("LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。")
extracted_content = llm_output.strip()
return extracted_content.replace("</script>", "<\\/script>")
@@ -958,27 +949,83 @@ class Action:
chat_id: str,
message_id: str,
markdown_syntax: str,
svg_width: int,
svg_height: int,
) -> str:
"""生成用于前端 SVG 渲染和图片嵌入的 JavaScript 代码"""
# 转义语法以便嵌入 JS
syntax_escaped = (
markdown_syntax
.replace("\\", "\\\\")
markdown_syntax.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const defaultWidth = {svg_width};
const defaultHeight = {svg_height};
const defaultWidth = 1200;
const defaultHeight = 800;
// 主题检测 - 检查 OpenWebUI 当前主题
const detectTheme = () => {{
try {{
// 1. 检查 html/body 的 class 或 data-theme 属性
const html = document.documentElement;
const body = document.body;
const htmlClass = html ? html.className : '';
const bodyClass = body ? body.className : '';
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
return 'dark';
}}
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
return 'light';
}}
// 2. 检查 meta theme-color
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (metas.length > 0) {{
const color = metas[metas.length - 1].content.trim();
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
if (m) {{
const hex = m[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luma < 0.5 ? 'dark' : 'light';
}}
}}
// 3. 检查系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
return 'dark';
}}
return 'light';
}} catch (e) {{
return 'light';
}}
}};
const currentTheme = detectTheme();
console.log("[思维导图图片] 检测到主题:", currentTheme);
// 基于主题的颜色配置
const colors = currentTheme === 'dark' ? {{
background: '#1f2937',
text: '#e5e7eb',
link: '#94a3b8',
nodeStroke: '#64748b'
}} : {{
background: '#ffffff',
text: '#1f2937',
link: '#546e7a',
nodeStroke: '#94a3b8'
}};
// 自动检测聊天容器宽度以实现自适应
let svgWidth = defaultWidth;
@@ -1053,7 +1100,7 @@ class Action:
svgEl.setAttribute('height', svgHeight);
svgEl.style.width = svgWidth + 'px';
svgEl.style.height = svgHeight + 'px';
svgEl.style.backgroundColor = '#ffffff';
svgEl.style.backgroundColor = colors.background;
container.appendChild(svgEl);
// 将 markdown 转换为树结构
@@ -1081,23 +1128,23 @@ class Action:
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
// 添加背景矩形
// 添加背景矩形(使用主题颜色)
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', '#ffffff');
bgRect.setAttribute('fill', colors.background);
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
// 添加内联样式
// 添加内联样式(使用主题颜色)
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
text {{ font-family: sans-serif; font-size: 14px; fill: #000000; }}
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: #000000; font-family: sans-serif; font-size: 14px; }}
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
strong {{ font-weight: 700; }}
.markmap-link {{ stroke: #546e7a; fill: none; }}
.markmap-node circle, .markmap-node rect {{ stroke: #94a3b8; }}
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
`;
clonedSvg.insertBefore(style, bgRect.nextSibling);
@@ -1109,7 +1156,7 @@ class Action:
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('x', fo.getAttribute('x') || '0');
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
textEl.setAttribute('fill', '#000000');
textEl.setAttribute('fill', colors.text);
textEl.setAttribute('font-family', 'sans-serif');
textEl.setAttribute('font-size', '14');
textEl.textContent = text.trim();
@@ -1119,21 +1166,63 @@ class Action:
// 序列化 SVG 为字符串
const svgData = new XMLSerializer().serializeToString(clonedSvg);
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUrl = 'data:image/svg+xml;base64,' + svgBase64;
console.log("[思维导图图片] Data URL 已生成,长度:", dataUrl.length);
// 清理
// 清理容器
document.body.removeChild(container);
// 生成 markdown 图片
const markdownImage = `![🧠 思维导图](${{dataUrl}})`;
// 将 SVG 字符串转换为 Blob
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
// 上传文件到 OpenWebUI API
console.log("[思维导图图片] 正在上传 SVG 文件...");
const token = localStorage.getItem("token");
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/files/', {{
method: 'POST',
headers: {{
'Authorization': `Bearer ${{token}}`
}},
body: formData
}});
if (!uploadResponse.ok) {{
throw new Error(`上传失败: ${{uploadResponse.statusText}}`);
}}
const fileData = await uploadResponse.json();
const fileId = fileData.id;
const imageUrl = `/api/v1/files/${{fileId}}/content`;
console.log("[思维导图图片] 文件已上传, ID:", fileId);
// 生成包含文件 URL 的 markdown 图片
const markdownImage = `![🧠 思维导图](${{imageUrl}})`;
// 通过 API 更新消息
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// 带重试逻辑的请求函数
const fetchWithRetry = async (url, options, retries = 3) => {{
for (let i = 0; i < retries; i++) {{
try {{
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {{
console.log(`[思维导图图片] 重试 ${{i + 1}}/${{retries}}: ${{url}}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}} catch (e) {{
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}}
return null;
}};
// 获取当前聊天数据
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
method: "GET",
@@ -1145,24 +1234,26 @@ class Action:
}}
const chatData = await getResponse.json();
let originalContent = "";
let updatedMessages = [];
let newContent = "";
if (chatData.chat && chatData.chat.messages) {{
updatedMessages = chatData.chat.messages.map(m => {{
if (m.id === messageId) {{
originalContent = m.content || "";
// 移除已有的思维导图图片
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
const originalContent = m.content || "";
// 移除已有的思维导图图片 (包括 base64 和文件 URL 格式)
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
let cleanedContent = originalContent.replace(mindmapPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// 追加新图片
const newContent = cleanedContent + "\\n\\n" + markdownImage;
newContent = cleanedContent + "\\n\\n" + markdownImage;
// 关键: 同时更新 messages 数组和 history 对象中的内容
// history 对象通常是数据库的单一真值来源
if (chatData.chat.history && chatData.chat.history.messages && chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
// history 对象是数据库的单一真值来源
if (chatData.chat.history && chatData.chat.history.messages) {{
if (chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
}}
}}
return {{ ...m, content: newContent }};
@@ -1171,28 +1262,40 @@ class Action:
}});
}}
// 第一步: 通过事件 API 更新前端显示(立即视觉反馈)
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: updatedMessages.find(m => m.id === messageId)?.content || "" }}
}})
}});
if (!newContent) {{
console.warn("[思维导图图片] 找不到要更新的消息");
return;
}}
// 第二步: 通过更新整个聊天来持久化到数据库
// 尝试通过事件 API 更新前端显示(可选,部分版本可能不支持)
try {{
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
}} catch (eventErr) {{
// 事件 API 是可选的,继续执行持久化
console.log("[思维导图图片] 事件 API 不可用,继续执行...");
}}
// 通过更新整个聊天对象来持久化到数据库
// 遵循 OpenWebUI 后端控制的 API 流程
const updatePayload = {{
chat: {{
...chatData.chat,
messages: updatedMessages
// history 已在上面原地更新
}}
}};
const persistResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
@@ -1201,22 +1304,13 @@ class Action:
body: JSON.stringify(updatePayload)
}});
if (persistResponse.ok) {{
if (persistResponse && persistResponse.ok) {{
console.log("[思维导图图片] ✅ 消息已持久化保存!");
}} else {{
console.error("[思维导图图片] 持久化 API 错误:", persistResponse.status);
// 尝试备用更新方法
const altResponse = await fetch(`/api/v1/chats/${{chatId}}/share`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}}
}});
console.log("[思维导图图片] 备用持久化尝试:", altResponse.status);
console.error("[思维导图图片] ❌ 重试后仍然无法持久化消息");
}}
}} else {{
console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId");
console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId,无法持久化");
}}
}} catch (error) {{
@@ -1234,7 +1328,7 @@ class Action:
__metadata__: Optional[dict] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: 思维导图 (v12 - Final Feedback Fix) started")
logger.info("Action: 思维导图 (v0.9.1) started")
user_ctx = self._get_user_context(__user__)
user_language = user_ctx["user_language"]
user_name = user_ctx["user_name"]
@@ -1416,30 +1510,28 @@ class Action:
# 图片模式: 使用 JavaScript 渲染并嵌入为 Markdown 图片
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
await self._emit_status(
__event_emitter__,
"思维导图: 正在渲染图片...",
False,
)
if __event_call__:
js_code = self._generate_image_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
markdown_syntax=markdown_syntax,
svg_width=self.valves.SVG_WIDTH,
svg_height=self.valves.SVG_HEIGHT,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(
__event_emitter__, "思维导图: 图片已生成!", True
)
@@ -1448,9 +1540,9 @@ class Action:
f"思维导图图片已生成,{user_name}!",
"success",
)
logger.info("Action: 思维导图 (v0.9.0) 图片模式完成")
logger.info("Action: 思维导图 (v0.9.1) 图片模式完成")
return body
# HTML 模式(默认): 嵌入为 HTML 块
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
@@ -1459,7 +1551,7 @@ class Action:
await self._emit_notification(
__event_emitter__, f"思维导图已生成,{user_name}!", "success"
)
logger.info("Action: 思维导图 (v0.9.0) HTML 模式完成")
logger.info("Action: 思维导图 (v0.9.1) HTML 模式完成")
except Exception as e:
error_message = f"思维导图处理失败: {str(e)}"

View File

@@ -6,6 +6,7 @@ author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
description: Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.
version: 1.1.0
openwebui_id: b1655bc8-6de9-4cad-8cb5-a6f7829a02ce
license: MIT
═══════════════════════════════════════════════════════════════════════════════

View File

@@ -6,6 +6,7 @@ author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
description: 通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。
version: 1.1.0
openwebui_id: 5c0617cb-a9e4-4bd6-a440-d276534ebd18
license: MIT
═══════════════════════════════════════════════════════════════════════════════

View File

@@ -140,22 +140,49 @@ def compare_versions(current: list[dict], previous_file: str) -> dict[str, list[
return {"added": current, "updated": [], "removed": []}
# Create lookup dictionaries by title
current_by_title = {p["title"]: p for p in current}
previous_by_title = {p["title"]: p for p in previous}
# Helper to extract title/version from either simple dict or raw post object
def get_info(p):
if "data" in p and "function" in p["data"]:
# It's a raw post object
manifest = p["data"]["function"].get("meta", {}).get("manifest", {})
title = manifest.get("title") or p.get("title")
version = manifest.get("version", "0.0.0")
return title, version, p
else:
# It's a simple dict
return p.get("title"), p.get("version"), p
current_by_title = {}
for p in current:
title, _, _ = get_info(p)
if title:
current_by_title[title] = p
previous_by_title = {}
for p in previous:
title, _, _ = get_info(p)
if title:
previous_by_title[title] = p
result = {"added": [], "updated": [], "removed": []}
# Find added and updated plugins
for title, plugin in current_by_title.items():
curr_title, curr_ver, _ = get_info(plugin)
if title not in previous_by_title:
result["added"].append(plugin)
elif plugin["version"] != previous_by_title[title]["version"]:
result["updated"].append(
{
"current": plugin,
"previous": previous_by_title[title],
}
)
else:
prev_plugin = previous_by_title[title]
_, prev_ver, _ = get_info(prev_plugin)
if curr_ver != prev_ver:
result["updated"].append(
{
"current": plugin,
"previous": prev_plugin,
}
)
# Find removed plugins
for title, plugin in previous_by_title.items():
@@ -212,9 +239,26 @@ def format_release_notes(
for update in comparison["updated"]:
curr = update["current"]
prev = update["previous"]
lines.append(
f"- **{curr['title']}**: v{prev['version']} → v{curr['version']}"
# Extract info safely
curr_manifest = (
curr.get("data", {})
.get("function", {})
.get("meta", {})
.get("manifest", {})
)
curr_title = curr_manifest.get("title") or curr.get("title")
curr_ver = curr_manifest.get("version") or curr.get("version")
prev_manifest = (
prev.get("data", {})
.get("function", {})
.get("meta", {})
.get("manifest", {})
)
prev_ver = prev_manifest.get("version") or prev.get("version")
lines.append(f"- **{curr_title}**: v{prev_ver} → v{curr_ver}")
lines.append("")
if comparison["removed"] and not ignore_removed:

View File

@@ -0,0 +1,50 @@
import json
import os
import sys
# Add current directory to path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from openwebui_stats import OpenWebUIStats
except ImportError:
print("Error: openwebui_stats.py not found.")
sys.exit(1)
def main():
# Try to get token from env
token = os.environ.get("OPENWEBUI_API_KEY")
if not token:
print("Error: OPENWEBUI_API_KEY environment variable not set.")
sys.exit(1)
print("Fetching remote plugins from OpenWebUI...")
client = OpenWebUIStats(token)
try:
posts = client.get_all_posts()
except Exception as e:
print(f"Error fetching posts: {e}")
sys.exit(1)
formatted_plugins = []
for post in posts:
# Save the full raw post object to ensure we have "compliant update json data"
# We inject a 'type' field just for the comparison script to know it's remote,
# but otherwise keep the structure identical to the API response.
post["type"] = "remote_plugin"
formatted_plugins.append(post)
output_file = "remote_versions.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(formatted_plugins, f, indent=2, ensure_ascii=False)
print(
f"✅ Successfully saved {len(formatted_plugins)} remote plugins to {output_file}"
)
print(f" You can now compare local vs remote using:")
print(f" python scripts/extract_plugin_versions.py --compare {output_file}")
if __name__ == "__main__":
main()

View File

@@ -250,7 +250,7 @@ class OpenWebUIStats:
texts = {
"zh": {
"title": "# 📊 OpenWebUI 社区统计报告",
"updated": f"> 📅 更新时间 (北京): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"updated": f"> 📅 更新时间: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"overview_title": "## 📈 总览",
"overview_header": "| 指标 | 数值 |",
"posts": "📝 发布数量",
@@ -265,7 +265,7 @@ class OpenWebUIStats:
},
"en": {
"title": "# 📊 OpenWebUI Community Stats Report",
"updated": f"> 📅 Updated (Beijing Time): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"updated": f"> 📅 Updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"overview_title": "## 📈 Overview",
"overview_header": "| Metric | Value |",
"posts": "📝 Total Posts",
@@ -339,27 +339,27 @@ class OpenWebUIStats:
stats: 统计数据
lang: 语言 ("zh" 中文, "en" 英文)
"""
# 获取 Top 5 插件
top_plugins = stats["posts"][:5]
# 获取 Top 6 插件
top_plugins = stats["posts"][:6]
# 中英文文本
texts = {
"zh": {
"title": "## 📊 社区统计",
"updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')} (北京时间)",
"updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"author_header": "| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |",
"header": "| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |",
"top5_title": "### 🔥 热门插件 Top 5",
"top5_header": "| 排名 | 插件 | 下载 | 浏览 |",
"full_stats": "*完整统计请查看 [社区统计报告](./docs/community-stats.md)*",
"top6_title": "### 🔥 热门插件 Top 6",
"top6_header": "| 排名 | 插件 | 下载 | 浏览 |",
"full_stats": "*完整统计请查看 [社区统计报告](./docs/community-stats.zh.md)*",
},
"en": {
"title": "## 📊 Community Stats",
"updated": f"> 🕐 Auto-updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')} (Beijing Time)",
"updated": f"> 🕐 Auto-updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"author_header": "| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |",
"header": "| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |",
"top5_title": "### 🔥 Top 5 Popular Plugins",
"top5_header": "| Rank | Plugin | Downloads | Views |",
"top6_title": "### 🔥 Top 6 Popular Plugins",
"top6_header": "| Rank | Plugin | Downloads | Views |",
"full_stats": "*See full stats in [Community Stats Report](./docs/community-stats.md)*",
},
}
@@ -395,13 +395,13 @@ class OpenWebUIStats:
)
lines.append("")
# Top 5 热门插件
lines.append(t["top5_title"])
# Top 6 热门插件
lines.append(t["top6_title"])
lines.append("")
lines.append(t["top5_header"])
lines.append(t["top6_header"])
lines.append("|:---:|------|:---:|:---:|")
medals = ["🥇", "🥈", "🥉", "4", "5"]
medals = ["🥇", "🥈", "🥉", "4", "5", "6"]
for i, post in enumerate(top_plugins):
medal = medals[i] if i < len(medals) else str(i + 1)
lines.append(
@@ -520,14 +520,14 @@ def main():
script_dir = Path(__file__).parent.parent
# 中文报告
md_zh_path = script_dir / "docs" / "community-stats.md"
md_zh_path = script_dir / "docs" / "community-stats.zh.md"
md_zh_content = stats_client.generate_markdown(stats, lang="zh")
with open(md_zh_path, "w", encoding="utf-8") as f:
f.write(md_zh_content)
print(f"\n✅ 中文报告已保存到: {md_zh_path}")
# 英文报告
md_en_path = script_dir / "docs" / "community-stats.en.md"
md_en_path = script_dir / "docs" / "community-stats.md"
md_en_content = stats_client.generate_markdown(stats, lang="en")
with open(md_en_path, "w", encoding="utf-8") as f:
f.write(md_en_content)

262
scripts/publish_plugin.py Normal file
View File

@@ -0,0 +1,262 @@
import os
import sys
import json
import requests
import re
def parse_frontmatter(content):
"""Extracts metadata from the python file docstring."""
# Allow leading whitespace and handle potential shebangs
match = re.search(r'^\s*"""\n(.*?)\n"""', content, re.DOTALL)
if not match:
# Fallback for files starting with comments or shebangs
match = re.search(r'"""\n(.*?)\n"""', content, re.DOTALL)
if not match:
return {}
frontmatter = match.group(1)
meta = {}
for line in frontmatter.split("\n"):
if ":" in line:
key, value = line.split(":", 1)
meta[key.strip()] = value.strip()
return meta
def sync_frontmatter(file_path, content, meta, post_data):
"""Syncs remote metadata back to local file frontmatter."""
changed = False
new_meta = meta.copy()
# 1. Sync ID
if "openwebui_id" not in new_meta and "post_id" not in new_meta:
new_meta["openwebui_id"] = post_data.get("id")
changed = True
# 2. Sync Icon URL (often set in UI)
manifest = (
post_data.get("data", {})
.get("function", {})
.get("meta", {})
.get("manifest", {})
)
if "icon_url" not in new_meta and manifest.get("icon_url"):
new_meta["icon_url"] = manifest.get("icon_url")
changed = True
# 3. Sync other fields if missing locally
for field in ["author", "author_url", "funding_url"]:
if field not in new_meta and manifest.get(field):
new_meta[field] = manifest.get(field)
changed = True
if changed:
print(f" Syncing metadata back to {os.path.basename(file_path)}...")
# Reconstruct frontmatter
# We need to replace the content inside the first """ ... """
# This is a bit fragile with regex but sufficient for standard files
def replacement(match):
lines = []
# Keep existing description or comments if we can't parse them easily?
# Actually, let's just reconstruct the key-values we know
# and try to preserve the description if it was at the end
# Simple approach: Rebuild the whole block based on new_meta
# This might lose comments inside the frontmatter, but standard format is simple keys
# Try to preserve order: title, author, ..., version, ..., description
ordered_keys = [
"title",
"author",
"author_url",
"funding_url",
"version",
"openwebui_id",
"icon_url",
"requirements",
"description",
]
block = ['"""']
# Add known keys in order
for k in ordered_keys:
if k in new_meta:
block.append(f"{k}: {new_meta[k]}")
# Add any other custom keys
for k, v in new_meta.items():
if k not in ordered_keys:
block.append(f"{k}: {v}")
block.append('"""')
return "\n".join(block)
new_content = re.sub(
r'^"""\n(.*?)\n"""', replacement, content, count=1, flags=re.DOTALL
)
# If regex didn't match (e.g. leading whitespace), try with whitespace
if new_content == content:
new_content = re.sub(
r'^\s*"""\n(.*?)\n"""', replacement, content, count=1, flags=re.DOTALL
)
if new_content != content:
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_content)
return new_content # Return updated content
return content
def update_plugin(file_path, post_id, token):
print(f"Processing {os.path.basename(file_path)} (ID: {post_id})...")
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
meta = parse_frontmatter(content)
if not meta:
print(f" Skipping: No frontmatter found.")
return False
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
# 1. Fetch existing post
try:
response = requests.get(
f"https://api.openwebui.com/api/v1/posts/{post_id}", headers=headers
)
response.raise_for_status()
post_data = response.json()
except Exception as e:
print(f" Error fetching post: {e}")
return False
# 1.5 Sync Metadata back to local file
try:
content = sync_frontmatter(file_path, content, meta, post_data)
# Re-parse meta in case it changed
meta = parse_frontmatter(content)
except Exception as e:
print(f" Warning: Failed to sync local metadata: {e}")
# 2. Update ONLY Content and Manifest
try:
# Ensure structure exists before populating nested fields
if "data" not in post_data:
post_data["data"] = {}
if "function" not in post_data["data"]:
post_data["data"]["function"] = {}
if "meta" not in post_data["data"]["function"]:
post_data["data"]["function"]["meta"] = {}
if "manifest" not in post_data["data"]["function"]["meta"]:
post_data["data"]["function"]["meta"]["manifest"] = {}
# Update 1: The Source Code (Inner Content)
post_data["data"]["function"]["content"] = content
# Update 2: The Post Body/README (Outer Content)
# Try to find a matching README file
plugin_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path).lower()
readme_content = None
# Determine preferred README filename
readme_files = []
if base_name.endswith("_cn.py"):
readme_files = ["README_CN.md", "README.md"]
else:
readme_files = ["README.md", "README_CN.md"]
for readme_name in readme_files:
readme_path = os.path.join(plugin_dir, readme_name)
if os.path.exists(readme_path):
try:
with open(readme_path, "r", encoding="utf-8") as f:
readme_content = f.read()
print(f" Using README: {readme_name}")
break
except Exception as e:
print(f" Error reading {readme_name}: {e}")
if readme_content:
post_data["content"] = readme_content
elif "description" in meta:
post_data["content"] = meta["description"]
else:
post_data["content"] = ""
# Update Manifest (Metadata)
post_data["data"]["function"]["meta"]["manifest"].update(meta)
# Sync top-level fields for consistency
if "title" in meta:
post_data["title"] = meta["title"]
post_data["data"]["function"]["name"] = meta["title"]
if "description" in meta:
post_data["data"]["function"]["meta"]["description"] = meta["description"]
except Exception as e:
print(f" Error preparing update: {e}")
return False
# 3. Submit Update
try:
response = requests.post(
f"https://api.openwebui.com/api/v1/posts/{post_id}/update",
headers=headers,
json=post_data,
)
response.raise_for_status()
print(f" ✅ Success!")
return True
except Exception as e:
print(f" ❌ Failed: {e}")
return False
def main():
token = os.environ.get("OPENWEBUI_API_KEY")
if not token:
print("Error: OPENWEBUI_API_KEY not set.")
sys.exit(1)
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
plugins_dir = os.path.join(base_dir, "plugins")
count = 0
# Walk through plugins directory
for root, _, files in os.walk(plugins_dir):
for file in files:
if file.endswith(".py"):
file_path = os.path.join(root, file)
# Check for ID in file content without full parse first
with open(file_path, "r", encoding="utf-8") as f:
content = f.read(
2000
) # Read first 2000 chars is enough for frontmatter
# Simple regex to find ID
id_match = re.search(
r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content
)
if id_match:
post_id = id_match.group(1).strip()
update_plugin(file_path, post_id, token)
count += 1
print(f"\nFinished. Updated {count} plugins.")
if __name__ == "__main__":
main()

132
scripts/sync_plugin_ids.py Normal file
View File

@@ -0,0 +1,132 @@
import os
import sys
import re
import difflib
# Add current directory to path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from openwebui_stats import OpenWebUIStats
from extract_plugin_versions import scan_plugins_directory
except ImportError:
print("Error: Helper scripts not found.")
sys.exit(1)
def normalize(s):
if not s:
return ""
return re.sub(r"\s+", " ", s.lower().strip())
def insert_id_into_file(file_path, post_id):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
new_lines = []
inserted = False
in_frontmatter = False
for line in lines:
# Check for start/end of frontmatter
if line.strip() == '"""':
if not in_frontmatter:
in_frontmatter = True
else:
# End of frontmatter
in_frontmatter = False
# Check if ID already exists
if in_frontmatter and (
line.strip().startswith("openwebui_id:")
or line.strip().startswith("post_id:")
):
print(f" ID already exists in {os.path.basename(file_path)}")
return False
new_lines.append(line)
# Insert after version
if in_frontmatter and not inserted and line.strip().startswith("version:"):
new_lines.append(f"openwebui_id: {post_id}\n")
inserted = True
if inserted:
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(new_lines)
return True
return False
def main():
token = os.environ.get("OPENWEBUI_API_KEY")
if not token:
print("Error: OPENWEBUI_API_KEY environment variable not set.")
sys.exit(1)
print("Fetching remote posts...")
client = OpenWebUIStats(token)
remote_posts = client.get_all_posts()
print(f"Fetched {len(remote_posts)} remote posts.")
plugins_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "plugins"
)
local_plugins = scan_plugins_directory(plugins_dir)
print(f"Found {len(local_plugins)} local plugins.")
matched_count = 0
for plugin in local_plugins:
local_title = plugin.get("title", "")
if not local_title:
continue
file_path = plugin.get("file_path")
best_match = None
highest_ratio = 0.0
# 1. Try Exact Match on Manifest Title (High Confidence)
for post in remote_posts:
manifest_title = (
post.get("data", {})
.get("function", {})
.get("meta", {})
.get("manifest", {})
.get("title")
)
if manifest_title and normalize(manifest_title) == normalize(local_title):
best_match = post
highest_ratio = 1.0
break
# 2. Try Fuzzy Match on Post Title if no exact match
if not best_match:
for post in remote_posts:
post_title = post.get("title", "")
ratio = difflib.SequenceMatcher(
None, normalize(local_title), normalize(post_title)
).ratio()
if ratio > 0.8 and ratio > highest_ratio:
highest_ratio = ratio
best_match = post
if best_match:
post_id = best_match.get("id")
post_title = best_match.get("title")
print(
f"Match found: '{local_title}' <--> '{post_title}' (ID: {post_id}) [Score: {highest_ratio:.2f}]"
)
if insert_id_into_file(file_path, post_id):
print(f" -> Updated {os.path.basename(file_path)}")
matched_count += 1
else:
print(f"No match found for: '{local_title}'")
print(f"\nTotal updated: {matched_count}")
if __name__ == "__main__":
main()