Compare commits

...

56 Commits

Author SHA1 Message Date
fujie
3cc4478dd9 feat(deep-dive): add Deep Dive / 精读 action plugin
- New thinking chain structure: Context → Logic → Insight → Path
- Process-oriented timeline UI design
- OpenWebUI theme auto-adaptation (light/dark)
- Full markdown support (numbered lists, inline code, bold)
- Bilingual support (English: Deep Dive, Chinese: 精读)
- Add manual publish workflow for new plugins
2026-01-08 08:37:50 +08:00
github-actions[bot]
59f6f2ba97 chore: update community stats 2026-01-08 2026-01-08 00:35:51 +00:00
github-actions[bot]
172d9e0b41 chore: update community stats 2026-01-07 2026-01-07 23:08:41 +00:00
github-actions[bot]
de7086c9e1 chore: update community stats 2026-01-07 2026-01-07 22:08:12 +00:00
github-actions[bot]
5f63e8d1e2 chore: update community stats 2026-01-07 2026-01-07 21:08:36 +00:00
github-actions[bot]
3da0b894fd chore: update community stats 2026-01-07 2026-01-07 20:09:35 +00:00
github-actions[bot]
ad2d26aa16 chore: update community stats 2026-01-07 2026-01-07 19:08:58 +00:00
github-actions[bot]
a09f3e0bdb chore: update community stats 2026-01-07 2026-01-07 18:12:18 +00:00
github-actions[bot]
3a0faf27df chore: update community stats 2026-01-07 2026-01-07 17:11:23 +00:00
fujie
cd3e7309a8 refactor: create OpenWebUICommunityClient class to unify API operations 2026-01-08 00:44:25 +08:00
fujie
54cc10bb41 feat: optimize publish script to skip unchanged versions 2026-01-08 00:34:49 +08:00
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
49 changed files with 4906 additions and 530 deletions

View File

@@ -39,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**:
@@ -82,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 规范

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

View File

@@ -0,0 +1,68 @@
name: Publish New Plugin
on:
workflow_dispatch:
inputs:
plugin_dir:
description: 'Plugin directory (e.g., plugins/actions/deep-dive)'
required: true
type: string
dry_run:
description: 'Dry run mode (preview only)'
required: false
type: boolean
default: false
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: Validate plugin directory
run: |
if [ ! -d "${{ github.event.inputs.plugin_dir }}" ]; then
echo "❌ Error: Directory '${{ github.event.inputs.plugin_dir }}' does not exist"
exit 1
fi
echo "✅ Found plugin directory: ${{ github.event.inputs.plugin_dir }}"
ls -la "${{ github.event.inputs.plugin_dir }}"
- name: Publish Plugin
env:
OPENWEBUI_API_KEY: ${{ secrets.OPENWEBUI_API_KEY }}
run: |
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
echo "🔍 Dry run mode - previewing..."
python scripts/publish_plugin.py --new "${{ github.event.inputs.plugin_dir }}" --dry-run
else
echo "🚀 Publishing plugin..."
python scripts/publish_plugin.py --new "${{ github.event.inputs.plugin_dir }}"
fi
- name: Commit changes (if ID was added)
if: ${{ github.event.inputs.dry_run != 'true' }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Check if there are changes to commit
if git diff --quiet; then
echo "No changes to commit"
else
git add "${{ github.event.inputs.plugin_dir }}"
git commit -m "feat: add openwebui_id to ${{ github.event.inputs.plugin_dir }}"
git push
echo "✅ Committed and pushed openwebui_id changes"
fi

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

@@ -193,10 +193,21 @@ jobs:
TODAY_PREFIX="v${TODAY}-"
# Count existing releases with today's date prefix
EXISTING_COUNT=$(gh release list --limit 100 | grep -c "^${TODAY_PREFIX}" || echo "0")
# 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"
@@ -334,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,25 +7,26 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu
<!-- STATS_START -->
## 📊 Community Stats
> 🕐 Auto-updated: 2026-01-06 21:19
> 🕐 Auto-updated: 2026-01-08 08:35
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **43** | **62** | **17** |
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **50** | **64** | **18** |
| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |
|:---:|:---:|:---:|:---:|:---:|
| **11** | **794** | **8481** | **54** | **48** |
| **11** | **916** | **9670** | **55** | **50** |
### 🔥 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) | 240 | 2133 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 171 | 459 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1236 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 76 | 1421 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 909 |
| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 294 | 2550 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 178 | 507 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 119 | 1308 |
| 4⃣ | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 87 | 1123 |
| 5⃣ | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | 84 | 1561 |
| 6⃣ | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 69 | 644 |
*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 21:19
> 🕐 自动更新于 2026-01-08 08:35
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **43** | **62** | **17** |
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **50** | **64** | **18** |
| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |
|:---:|:---:|:---:|:---:|:---:|
| **11** | **794** | **8481** | **54** | **48** |
| **11** | **916** | **9670** | **55** | **50** |
### 🔥 热门插件 Top 5
### 🔥 热门插件 Top 6
| 排名 | 插件 | 下载 | 浏览 |
|:---:|------|:---:|:---:|
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 240 | 2133 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 171 | 459 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1236 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 76 | 1421 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 909 |
| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 294 | 2550 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 178 | 507 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 119 | 1308 |
| 4⃣ | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 87 | 1123 |
| 5⃣ | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | 84 | 1561 |
| 6⃣ | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 69 | 644 |
*完整统计请查看 [社区统计报告](./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 21:19
## 📈 Overview
| Metric | Value |
|------|------|
| 📝 Total Posts | 11 |
| ⬇️ Total Downloads | 794 |
| 👁️ Total Views | 8481 |
| 👍 Total Upvotes | 54 |
| 💾 Total Saves | 48 |
| 💬 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 | 240 | 2133 | 10 | 15 | 2026-01-03 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 171 | 459 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1236 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 76 | 1421 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 909 | 6 | 8 | 2026-01-03 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 504 | 5 | 4 | 2026-01-05 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 398 | 3 | 0 | 2025-12-29 |
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 15 | 752 | 7 | 1 | 2026-01-05 |
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 249 | 2 | 1 | 2025-12-31 |
| 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 | 111 | 2 | 1 | 2025-12-31 |

View File

@@ -1,46 +1,46 @@
{
"total_posts": 11,
"total_downloads": 794,
"total_views": 8481,
"total_upvotes": 54,
"total_downloads": 916,
"total_views": 9670,
"total_upvotes": 55,
"total_downvotes": 1,
"total_saves": 48,
"total_comments": 13,
"total_saves": 50,
"total_comments": 15,
"by_type": {
"action": 9,
"filter": 2
},
"posts": [
{
"title": "Turn Any Text into Beautiful Mind Maps",
"title": "Smart Mind Map",
"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": 240,
"views": 2133,
"downloads": 294,
"views": 2550,
"upvotes": 10,
"saves": 15,
"comments": 8,
"saves": 16,
"comments": 10,
"created_at": "2025-12-30",
"updated_at": "2026-01-03",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a"
},
{
"title": "Export to Excel",
"slug": "export_mulit_table_to_excel_244b8f9d",
"type": "action",
"version": "0.3.6",
"version": "0.3.7",
"author": "Fu-Jie",
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
"downloads": 171,
"views": 459,
"downloads": 178,
"views": 507,
"upvotes": 3,
"saves": 3,
"comments": 0,
"created_at": "2025-05-30",
"updated_at": "2026-01-03",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d"
},
{
@@ -49,126 +49,126 @@
"type": "filter",
"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": 1236,
"description": "Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.",
"downloads": 119,
"views": 1308,
"upvotes": 5,
"saves": 9,
"comments": 0,
"created_at": "2025-11-08",
"updated_at": "2025-12-31",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/async_context_compression_b1655bc8"
},
{
"title": "Flash Card ",
"title": "📊 Smart Infographic (AntV)",
"slug": "smart_infographic_ad6f0c7f",
"type": "action",
"version": "1.4.1",
"author": "jeff",
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
"downloads": 87,
"views": 1123,
"upvotes": 7,
"saves": 8,
"comments": 2,
"created_at": "2025-12-28",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
},
{
"title": "Flash Card",
"slug": "flash_card_65a2ea8f",
"type": "action",
"version": "0.2.4",
"author": "Fu-Jie",
"description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
"downloads": 76,
"views": 1421,
"downloads": 84,
"views": 1561,
"upvotes": 8,
"saves": 5,
"comments": 2,
"created_at": "2025-12-30",
"updated_at": "2026-01-03",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/flash_card_65a2ea8f"
},
{
"title": "Smart Infographic",
"slug": "smart_infographic_ad6f0c7f",
"type": "action",
"version": "1.3.2",
"author": "jeff",
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
"downloads": 65,
"views": 909,
"upvotes": 6,
"saves": 8,
"comments": 2,
"created_at": "2025-12-28",
"updated_at": "2026-01-03",
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
},
{
"title": "Export to Word (Enhanced Formatting)",
"title": "Export to Word (Enhanced)",
"slug": "export_to_word_enhanced_formatting_fca6a315",
"type": "action",
"version": "0.4.0",
"version": "0.4.3",
"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": 504,
"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.",
"downloads": 69,
"views": 644,
"upvotes": 5,
"saves": 4,
"saves": 5,
"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"
},
{
"title": "智能信息图",
"title": "📊 智能信息图 (AntV Infographic)",
"slug": "智能信息图_e04a48ff",
"type": "action",
"version": "1.3.1",
"version": "1.4.1",
"author": "jeff",
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
"downloads": 33,
"views": 398,
"views": 434,
"upvotes": 3,
"saves": 0,
"comments": 0,
"created_at": "2025-12-28",
"updated_at": "2025-12-29",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
},
{
"title": "导出为 Word-支持公式、流程图、表格和代码块",
"title": "导出为 Word (增强版)",
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
"type": "action",
"version": "0.4.1",
"version": "0.4.3",
"author": "Fu-Jie",
"description": "将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持中英文无乱码。",
"downloads": 15,
"views": 752,
"description": "将对话导出为 Word (.docx),支持 Mermaid 图表 (客户端渲染 SVG+PNG)、LaTeX 数学公式、真实超链接、增强表格格式、代码高亮和引用块。",
"downloads": 20,
"views": 815,
"upvotes": 7,
"saves": 1,
"comments": 1,
"created_at": "2026-01-04",
"updated_at": "2026-01-05",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
},
{
"title": "智能生成交互式思维导图,帮助用户可视化知识",
"title": "思维导图",
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
"type": "action",
"version": "0.8.0",
"author": "",
"version": "0.9.1",
"author": "Fu-Jie",
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
"downloads": 14,
"views": 249,
"downloads": 15,
"views": 273,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2025-12-31",
"updated_at": "2025-12-31",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b"
},
{
"title": "闪记卡生成插件",
"title": "闪记卡 (Flash Card)",
"slug": "闪记卡生成插件_4a31eac3",
"type": "action",
"version": "0.2.2",
"version": "0.2.4",
"author": "Fu-Jie",
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
"downloads": 12,
"views": 309,
"views": 329,
"upvotes": 3,
"saves": 1,
"comments": 0,
"created_at": "2025-12-30",
"updated_at": "2025-12-31",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/闪记卡生成插件_4a31eac3"
},
{
@@ -177,14 +177,14 @@
"type": "filter",
"version": "1.1.0",
"author": "Fu-Jie",
"description": "在 LLM 响应完成后进行上下文摘要和压缩",
"description": "通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。",
"downloads": 5,
"views": 111,
"views": 126,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2025-11-08",
"updated_at": "2025-12-31",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/异步上下文压缩_5c0617cb"
}
],
@@ -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": 43,
"followers": 50,
"following": 2,
"total_points": 62,
"post_points": 53,
"comment_points": 9,
"contributions": 17
"total_points": 64,
"post_points": 54,
"comment_points": 10,
"contributions": 18
}
}

View File

@@ -1,35 +1,35 @@
# 📊 OpenWebUI 社区统计报告
# 📊 OpenWebUI Community Stats Report
> 📅 更新时间: 2026-01-06 21:19
> 📅 Updated: 2026-01-08 08:35
## 📈 总览
## 📈 Overview
| 指标 | 数值 |
| Metric | Value |
|------|------|
| 📝 发布数量 | 11 |
| ⬇️ 总下载量 | 794 |
| 👁️ 总浏览量 | 8481 |
| 👍 总点赞数 | 54 |
| 💾 总收藏数 | 48 |
| 💬 总评论数 | 13 |
| 📝 Total Posts | 11 |
| ⬇️ Total Downloads | 916 |
| 👁️ Total Views | 9670 |
| 👍 Total Upvotes | 55 |
| 💾 Total Saves | 50 |
| 💬 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 | 240 | 2133 | 10 | 15 | 2026-01-03 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 171 | 459 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1236 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 76 | 1421 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 909 | 6 | 8 | 2026-01-03 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 504 | 5 | 4 | 2026-01-05 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 398 | 3 | 0 | 2025-12-29 |
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 15 | 752 | 7 | 1 | 2026-01-05 |
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 249 | 2 | 1 | 2025-12-31 |
| 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 | 111 | 2 | 1 | 2025-12-31 |
| 1 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 294 | 2550 | 10 | 16 | 2026-01-07 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.7 | 178 | 507 | 3 | 3 | 2026-01-07 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 119 | 1308 | 5 | 9 | 2026-01-07 |
| 4 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.1 | 87 | 1123 | 7 | 8 | 2026-01-07 |
| 5 | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 84 | 1561 | 8 | 5 | 2026-01-07 |
| 6 | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.3 | 69 | 644 | 5 | 5 | 2026-01-07 |
| 7 | [📊 智能信息图 (AntV Infographic)](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.4.1 | 33 | 434 | 3 | 0 | 2026-01-07 |
| 8 | [导出为 Word (增强版)](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.3 | 20 | 815 | 7 | 1 | 2026-01-07 |
| 9 | [思维导图](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.9.1 | 15 | 273 | 2 | 1 | 2026-01-07 |
| 10 | [闪记卡 (Flash Card)](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.4 | 12 | 329 | 3 | 1 | 2026-01-07 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 126 | 2 | 1 | 2026-01-07 |

View File

@@ -0,0 +1,35 @@
# 📊 OpenWebUI 社区统计报告
> 📅 更新时间: 2026-01-08 08:35
## 📈 总览
| 指标 | 数值 |
|------|------|
| 📝 发布数量 | 11 |
| ⬇️ 总下载量 | 916 |
| 👁️ 总浏览量 | 9670 |
| 👍 总点赞数 | 55 |
| 💾 总收藏数 | 50 |
| 💬 总评论数 | 15 |
## 📂 按类型分类
- **action**: 9
- **filter**: 2
## 📋 发布列表
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 294 | 2550 | 10 | 16 | 2026-01-07 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.7 | 178 | 507 | 3 | 3 | 2026-01-07 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 119 | 1308 | 5 | 9 | 2026-01-07 |
| 4 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.1 | 87 | 1123 | 7 | 8 | 2026-01-07 |
| 5 | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 84 | 1561 | 8 | 5 | 2026-01-07 |
| 6 | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.3 | 69 | 644 | 5 | 5 | 2026-01-07 |
| 7 | [📊 智能信息图 (AntV Infographic)](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.4.1 | 33 | 434 | 3 | 0 | 2026-01-07 |
| 8 | [导出为 Word (增强版)](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.3 | 20 | 815 | 7 | 1 | 2026-01-07 |
| 9 | [思维导图](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.9.1 | 15 | 273 | 2 | 1 | 2026-01-07 |
| 10 | [闪记卡 (Flash Card)](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.4 | 12 | 329 | 3 | 1 | 2026-01-07 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 126 | 2 | 1 | 2026-01-07 |

View File

@@ -0,0 +1,111 @@
# Deep Dive
<span class="category-badge action">Action</span>
<span class="version-badge">v1.0.0</span>
A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.
---
## Overview
The Deep Dive plugin transforms how you understand complex content by guiding you through a structured thinking process. Rather than just summarizing, it deconstructs content across four phases:
- **🔍 The Context (What?)**: Panoramic view of the situation and background
- **🧠 The Logic (Why?)**: Deconstruction of reasoning and mental models
- **💎 The Insight (So What?)**: Non-obvious value and hidden implications
- **🚀 The Path (Now What?)**: Specific, prioritized strategic actions
## Features
- :material-brain: **Thinking Chain**: Complete structured analysis process
- :material-eye: **Deep Understanding**: Reveals hidden assumptions and blind spots
- :material-lightbulb-on: **Insight Extraction**: Finds the "Aha!" moments
- :material-rocket-launch: **Action Oriented**: Translates understanding into actionable steps
- :material-theme-light-dark: **Theme Adaptive**: Auto-adapts to OpenWebUI light/dark theme
- :material-translate: **Multi-language**: Outputs in user's preferred language
---
## Installation
1. Download the plugin file: [`deep_dive.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive)
2. Upload to OpenWebUI: **Admin Panel****Settings****Functions**
3. Enable the plugin
---
## Usage
1. Provide any long text, article, or meeting notes in the chat
2. Click the **Deep Dive** button in the message action bar
3. Follow the visual timeline from Context → Logic → Insight → Path
---
## Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `SHOW_STATUS` | boolean | `true` | Show status updates during processing |
| `MODEL_ID` | string | `""` | LLM model for analysis (empty = current model) |
| `MIN_TEXT_LENGTH` | integer | `200` | Minimum text length for analysis |
| `CLEAR_PREVIOUS_HTML` | boolean | `true` | Clear previous plugin results |
| `MESSAGE_COUNT` | integer | `1` | Number of recent messages to analyze |
---
## Theme Support
Deep Dive automatically adapts to OpenWebUI's light/dark theme:
- Detects theme from parent document `<meta name="theme-color">` tag
- Falls back to `html/body` class or `data-theme` attribute
- Uses system preference `prefers-color-scheme: dark` as last resort
!!! tip "For Best Results"
Enable **iframe Sandbox Allow Same Origin** in OpenWebUI:
**Settings****Interface****Artifacts** → Check **iframe Sandbox Allow Same Origin**
---
## Example Output
The plugin generates a beautiful structured timeline:
```
┌─────────────────────────────────────┐
│ 🌊 Deep Dive Analysis │
│ 👤 User 📅 Date 📊 Word count │
├─────────────────────────────────────┤
│ 🔍 Phase 01: The Context │
│ [High-level panoramic view] │
│ │
│ 🧠 Phase 02: The Logic │
│ • Reasoning structure... │
│ • Hidden assumptions... │
│ │
│ 💎 Phase 03: The Insight │
│ • Non-obvious value... │
│ • Blind spots revealed... │
│ │
│ 🚀 Phase 04: The Path │
│ ▸ Priority Action 1... │
│ ▸ Priority Action 2... │
└─────────────────────────────────────┘
```
---
## Requirements
!!! note "Prerequisites"
- OpenWebUI v0.3.0 or later
- Uses the active LLM model for analysis
- Requires `markdown` Python package
---
## Source Code
[:fontawesome-brands-github: View on GitHub](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive){ .md-button }

View File

@@ -0,0 +1,111 @@
# 精读 (Deep Dive)
<span class="category-badge action">Action</span>
<span class="version-badge">v1.0.0</span>
全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
---
## 概述
精读插件改变了您理解复杂内容的方式,通过结构化的思维过程引导您进行深度分析。它不仅仅是摘要,而是从四个阶段解构内容:
- **🔍 全景 (The Context)**: 情境与背景的高层级全景视图
- **🧠 脉络 (The Logic)**: 解构底层推理逻辑与思维模型
- **💎 洞察 (The Insight)**: 提取非显性价值与隐藏含义
- **🚀 路径 (The Path)**: 具体的、按优先级排列的战略行动
## 功能特性
- :material-brain: **思维链**: 完整的结构化分析过程
- :material-eye: **深度理解**: 揭示隐藏的假设和思维盲点
- :material-lightbulb-on: **洞察提取**: 发现"原来如此"的时刻
- :material-rocket-launch: **行动导向**: 将深度理解转化为可执行步骤
- :material-theme-light-dark: **主题自适应**: 自动适配 OpenWebUI 深色/浅色主题
- :material-translate: **多语言**: 以用户偏好语言输出
---
## 安装
1. 下载插件文件: [`deep_dive_cn.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive)
2. 上传到 OpenWebUI: **管理面板****设置****Functions**
3. 启用插件
---
## 使用方法
1. 在聊天中提供任何长文本、文章或会议记录
2. 点击消息操作栏中的 **精读** 按钮
3. 沿着视觉时间轴从"全景"探索到"路径"
---
## 配置参数
| 选项 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `SHOW_STATUS` | boolean | `true` | 处理过程中是否显示状态更新 |
| `MODEL_ID` | string | `""` | 用于分析的 LLM 模型(空 = 当前模型) |
| `MIN_TEXT_LENGTH` | integer | `200` | 分析所需的最小文本长度 |
| `CLEAR_PREVIOUS_HTML` | boolean | `true` | 是否清除之前的插件结果 |
| `MESSAGE_COUNT` | integer | `1` | 要分析的最近消息数量 |
---
## 主题支持
精读插件自动适配 OpenWebUI 的深色/浅色主题:
- 从父文档 `<meta name="theme-color">` 标签检测主题
- 回退到 `html/body` 的 class 或 `data-theme` 属性
- 最后使用系统偏好 `prefers-color-scheme: dark`
!!! tip "最佳效果"
请在 OpenWebUI 中启用 **iframe Sandbox Allow Same Origin**
**设置****界面****Artifacts** → 勾选 **iframe Sandbox Allow Same Origin**
---
## 输出示例
插件生成精美的结构化时间轴:
```
┌─────────────────────────────────────┐
│ 📖 精读分析报告 │
│ 👤 用户 📅 日期 📊 字数 │
├─────────────────────────────────────┤
│ 🔍 阶段 01: 全景 (The Context) │
│ [高层级全景视图内容] │
│ │
│ 🧠 阶段 02: 脉络 (The Logic) │
│ • 推理结构分析... │
│ • 隐藏假设识别... │
│ │
│ 💎 阶段 03: 洞察 (The Insight) │
│ • 非显性价值提取... │
│ • 思维盲点揭示... │
│ │
│ 🚀 阶段 04: 路径 (The Path) │
│ ▸ 优先级行动 1... │
│ ▸ 优先级行动 2... │
└─────────────────────────────────────┘
```
---
## 系统要求
!!! note "前提条件"
- OpenWebUI v0.3.0 或更高版本
- 使用当前活跃的 LLM 模型进行分析
- 需要 `markdown` Python 包
---
## 源代码
[:fontawesome-brands-github: 在 GitHub 上查看](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive){ .md-button }

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,19 +63,19 @@ 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)
- :material-text-box-search:{ .lg .middle } **Summary**
- :material-brain:{ .lg .middle } **Deep Dive**
---
Generate concise summaries of long text content with key points extraction.
A comprehensive thinking lens that dives deep into any content - Context → Logic → Insight → Path. Supports theme auto-adaptation.
**Version:** 0.1.0
**Version:** 1.0.0
[:octicons-arrow-right-24: Documentation](summary.md)
[:octicons-arrow-right-24: Documentation](deep-dive.md)
- :material-image-text:{ .lg .middle } **Infographic to Markdown**

View File

@@ -33,7 +33,7 @@ Actions 是交互式插件,能够:
使用 AntV 可视化引擎,将文本转成专业的信息图。
**版本:** 1.3.0
**版本:** 1.4.1
[:octicons-arrow-right-24: 查看文档](smart-infographic.md)
@@ -63,19 +63,19 @@ Actions 是交互式插件,能够:
将当前对话导出为完美格式的 Word 文档,支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用资料**以及**增强表格**渲染。
**版本:** 0.4.1
**版本:** 0.4.2
[:octicons-arrow-right-24: 查看文档](export-to-word.md)
- :material-text-box-search:{ .lg .middle } **Summary**
- :material-brain:{ .lg .middle } **精读 (Deep Dive)**
---
对长文本进行精简总结,提取要点
全方位的思维透镜 —— 全景 → 脉络 → 洞察 → 路径。支持主题自适应
**版本:** 0.1.0
**版本:** 1.0.0
[:octicons-arrow-right-24: 查看文档](summary.md)
[:octicons-arrow-right-24: 查看文档](deep-dive.zh.md)
- :material-image-text:{ .lg .middle } **信息图转 Markdown**

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

@@ -0,0 +1,83 @@
# 🌊 Deep Dive
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.0.0 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.
## 🔥 What's New in v1.0.0
-**Thinking Chain Structure**: Moves from surface understanding to deep strategic action.
- 🔍 **Phase 01: The Context**: Panoramic view of the situation and background.
- 🧠 **Phase 02: The Logic**: Deconstruction of the underlying reasoning and mental models.
- 💎 **Phase 03: The Insight**: Extraction of non-obvious value and hidden implications.
- 🚀 **Phase 04: The Path**: Definition of specific, prioritized strategic directions.
- 🎨 **Premium UI**: Modern, process-oriented design with a "Thinking Line" timeline.
- 🌗 **Theme Adaptive**: Automatically adapts to OpenWebUI's light/dark theme.
## ✨ Key Features
- 🌊 **Deep Thinking**: Not just a summary, but a full deconstruction of content.
- 🧠 **Logical Analysis**: Reveals how arguments are built and identifies hidden assumptions.
- 💎 **Value Extraction**: Finds the "Aha!" moments and blind spots.
- 🚀 **Action Oriented**: Translates deep understanding into immediate, actionable steps.
- 🌍 **Multi-language**: Automatically adapts to the user's preferred language.
- 🌗 **Theme Support**: Seamlessly switches between light and dark themes based on OpenWebUI settings.
## 🚀 How to Use
1. **Input Content**: Provide any text, article, or meeting notes in the chat.
2. **Trigger Deep Dive**: Click the **Deep Dive** action button.
3. **Explore the Chain**: Follow the visual timeline from Context to Path.
## ⚙️ Configuration (Valves)
| Parameter | Default | Description |
| :--- | :--- | :--- |
| **Show Status (SHOW_STATUS)** | `True` | Whether to show status updates during the thinking process. |
| **Model ID (MODEL_ID)** | `Empty` | LLM model for analysis. Empty = use current model. |
| **Min Text Length (MIN_TEXT_LENGTH)** | `200` | Minimum characters required for a meaningful deep dive. |
| **Clear Previous HTML (CLEAR_PREVIOUS_HTML)** | `True` | Whether to clear previous plugin results. |
| **Message Count (MESSAGE_COUNT)** | `1` | Number of recent messages to analyze. |
## 🌗 Theme Support
The plugin automatically detects and adapts to OpenWebUI's theme settings:
- **Detection Priority**:
1. Parent document `<meta name="theme-color">` tag
2. Parent document `html/body` class or `data-theme` attribute
3. System preference via `prefers-color-scheme: dark`
- **Requirements**: For best results, enable **iframe Sandbox Allow Same Origin** in OpenWebUI:
- Go to **Settings****Interface****Artifacts** → Check **iframe Sandbox Allow Same Origin**
## 🎨 Visual Preview
The plugin generates a structured thinking timeline:
```
┌─────────────────────────────────────┐
│ 🌊 Deep Dive Analysis │
│ 👤 User 📅 Date 📊 Word count │
├─────────────────────────────────────┤
│ 🔍 Phase 01: The Context │
│ [High-level panoramic view] │
│ │
│ 🧠 Phase 02: The Logic │
│ • Reasoning structure... │
│ • Hidden assumptions... │
│ │
│ 💎 Phase 03: The Insight │
│ • Non-obvious value... │
│ • Blind spots revealed... │
│ │
│ 🚀 Phase 04: The Path │
│ ▸ Priority Action 1... │
│ ▸ Priority Action 2... │
└─────────────────────────────────────┘
```
## 📂 Files
- `deep_dive.py` - English version
- `deep_dive_cn.py` - Chinese version (精读)

View File

@@ -0,0 +1,83 @@
# 📖 精读
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.0.0 | **项目:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
## 🔥 v1.0.0 更新内容
-**思维链结构**: 从表面理解一步步深入到战略行动。
- 🔍 **阶段 01: 全景 (The Context)**: 提供情境与背景的高层级全景视图。
- 🧠 **阶段 02: 脉络 (The Logic)**: 解构底层推理逻辑与思维模型。
- 💎 **阶段 03: 洞察 (The Insight)**: 提取非显性价值与隐藏的深层含义。
- 🚀 **阶段 04: 路径 (The Path)**: 定义具体的、按优先级排列的战略方向。
- 🎨 **高端 UI**: 现代化的过程导向设计,带有"思维导火索"时间轴。
- 🌗 **主题自适应**: 自动适配 OpenWebUI 的深色/浅色主题。
## ✨ 核心特性
- 📖 **深度思考**: 不仅仅是摘要,而是对内容的全面解构。
- 🧠 **逻辑分析**: 揭示论点是如何构建的,识别隐藏的假设。
- 💎 **价值提取**: 发现"原来如此"的时刻与思维盲点。
- 🚀 **行动导向**: 将深度理解转化为立即、可执行的步骤。
- 🌍 **多语言支持**: 自动适配用户的偏好语言。
- 🌗 **主题支持**: 根据 OpenWebUI 设置自动切换深色/浅色主题。
## 🚀 如何使用
1. **输入内容**: 在聊天中提供任何文本、文章或会议记录。
2. **触发精读**: 点击 **精读** 操作按钮。
3. **探索思维链**: 沿着视觉时间轴从"全景"探索到"路径"。
## ⚙️ 配置参数 (Valves)
| 参数 | 默认值 | 描述 |
| :--- | :--- | :--- |
| **显示状态 (SHOW_STATUS)** | `True` | 是否在思维过程中显示状态更新。 |
| **模型 ID (MODEL_ID)** | `空` | 用于分析的 LLM 模型。留空 = 使用当前模型。 |
| **最小文本长度 (MIN_TEXT_LENGTH)** | `200` | 进行有意义的精读所需的最小字符数。 |
| **清除旧 HTML (CLEAR_PREVIOUS_HTML)** | `True` | 是否清除之前的插件结果。 |
| **消息数量 (MESSAGE_COUNT)** | `1` | 要分析的最近消息数量。 |
## 🌗 主题支持
插件会自动检测并适配 OpenWebUI 的主题设置:
- **检测优先级**:
1. 父文档 `<meta name="theme-color">` 标签
2. 父文档 `html/body` 的 class 或 `data-theme` 属性
3. 系统偏好 `prefers-color-scheme: dark`
- **环境要求**: 为获得最佳效果,请在 OpenWebUI 中启用 **iframe Sandbox Allow Same Origin**
- 进入 **设置****界面****Artifacts** → 勾选 **iframe Sandbox Allow Same Origin**
## 🎨 视觉预览
插件生成结构化的思维时间轴:
```
┌─────────────────────────────────────┐
│ 📖 精读分析报告 │
│ 👤 用户 📅 日期 📊 字数 │
├─────────────────────────────────────┤
│ 🔍 阶段 01: 全景 (The Context) │
│ [高层级全景视图内容] │
│ │
│ 🧠 阶段 02: 脉络 (The Logic) │
│ • 推理结构分析... │
│ • 隐藏假设识别... │
│ │
│ 💎 阶段 03: 洞察 (The Insight) │
│ • 非显性价值提取... │
│ • 思维盲点揭示... │
│ │
│ 🚀 阶段 04: 路径 (The Path) │
│ ▸ 优先级行动 1... │
│ ▸ 优先级行动 2... │
└─────────────────────────────────────┘
```
## 📂 文件说明
- `deep_dive.py` - 英文版 (Deep Dive)
- `deep_dive_cn.py` - 中文版 (精读)

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 KiB

View File

@@ -0,0 +1,884 @@
"""
title: Deep Dive
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 1.0.0
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiA3djE0Ii8+PHBhdGggZD0iTTMgMThhMSAxIDAgMCAxLTEtMVY0YTEgMSAwIDAgMSAxLTFoNWE0IDQgMCAwIDEgNCA0IDQgNCAwIDAgMSA0LTRoNWExIDEgMCAwIDEgMSAxdjEzYTEgMSAwIDAgMS0xIDFoLTZhMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAtMy0zeiIvPjxwYXRoIGQ9Ik02IDEyaDIiLz48cGF0aCBkPSJNMTYgMTJoMiIvPjwvc3ZnPg==
requirements: markdown
description: A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.
"""
# Standard library imports
import re
import logging
from typing import Optional, Dict, Any, Callable, Awaitable
from datetime import datetime
# Third-party imports
from pydantic import BaseModel, Field
from fastapi import Request
import markdown
# OpenWebUI imports
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
# Logging setup
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# =================================================================
# HTML Template - Process-Oriented Design with Theme Support
# =================================================================
HTML_WRAPPER_TEMPLATE = """
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
<!DOCTYPE html>
<html lang="{user_language}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--dd-bg-primary: #ffffff;
--dd-bg-secondary: #f8fafc;
--dd-bg-tertiary: #f1f5f9;
--dd-text-primary: #0f172a;
--dd-text-secondary: #334155;
--dd-text-dim: #64748b;
--dd-border: #e2e8f0;
--dd-accent: #3b82f6;
--dd-accent-soft: #eff6ff;
--dd-header-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
--dd-shadow: 0 10px 40px rgba(0,0,0,0.06);
--dd-code-bg: #f1f5f9;
}
.theme-dark {
--dd-bg-primary: #1e293b;
--dd-bg-secondary: #0f172a;
--dd-bg-tertiary: #334155;
--dd-text-primary: #f1f5f9;
--dd-text-secondary: #e2e8f0;
--dd-text-dim: #94a3b8;
--dd-border: #475569;
--dd-accent: #60a5fa;
--dd-accent-soft: rgba(59, 130, 246, 0.15);
--dd-header-gradient: linear-gradient(135deg, #0f172a 0%, #1e1e2e 100%);
--dd-shadow: 0 10px 40px rgba(0,0,0,0.3);
--dd-code-bg: #334155;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 10px;
background-color: transparent;
}
#main-container {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.plugin-item {
background: var(--dd-bg-primary);
border-radius: 24px;
box-shadow: var(--dd-shadow);
overflow: hidden;
border: 1px solid var(--dd-border);
}
/* STYLES_INSERTION_POINT */
</style>
</head>
<body>
<div id="main-container">
<!-- CONTENT_INSERTION_POINT -->
</div>
<!-- SCRIPTS_INSERTION_POINT -->
<script>
(function() {
const parseColorLuma = (colorStr) => {
if (!colorStr) return null;
let m = colorStr.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);
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
m = colorStr.match(/rgba?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/i);
if (m) {
const r = parseInt(m[1], 10);
const g = parseInt(m[2], 10);
const b = parseInt(m[3], 10);
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
return null;
};
const getThemeFromMeta = (doc) => {
const metas = Array.from((doc || document).querySelectorAll('meta[name="theme-color"]'));
if (!metas.length) return null;
const color = metas[metas.length - 1].content.trim();
const luma = parseColorLuma(color);
if (luma === null) return null;
return luma < 0.5 ? 'dark' : 'light';
};
const getParentDocumentSafe = () => {
try {
if (!window.parent || window.parent === window) return null;
const pDoc = window.parent.document;
void pDoc.title;
return pDoc;
} catch (err) { return null; }
};
const getThemeFromParentClass = () => {
try {
if (!window.parent || window.parent === window) return null;
const pDoc = window.parent.document;
const html = pDoc.documentElement;
const body = pDoc.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';
return null;
} catch (err) { return null; }
};
const setTheme = () => {
const parentDoc = getParentDocumentSafe();
const metaTheme = parentDoc ? getThemeFromMeta(parentDoc) : null;
const parentClassTheme = getThemeFromParentClass();
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const chosen = metaTheme || parentClassTheme || (prefersDark ? 'dark' : 'light');
document.documentElement.classList.toggle('theme-dark', chosen === 'dark');
};
setTheme();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', setTheme);
}
})();
</script>
</body>
</html>
"""
# =================================================================
# LLM Prompts - Deep Dive Thinking Chain
# =================================================================
SYSTEM_PROMPT = """
You are a Deep Dive Analyst. Your goal is to guide the user through a comprehensive thinking process, moving from surface understanding to deep strategic action.
## Thinking Structure (STRICT)
You MUST analyze the input across these four specific dimensions:
### 1. 🔍 The Context (What?)
Provide a high-level panoramic view. What is this content about? What is the core situation, background, or problem being addressed? (2-3 paragraphs)
### 2. 🧠 The Logic (Why?)
Deconstruct the underlying structure. How is the argument built? What is the reasoning, the hidden assumptions, or the mental models at play? (Bullet points)
### 3. 💎 The Insight (So What?)
Extract the non-obvious value. What are the "Aha!" moments? What are the implications, the blind spots, or the unique perspectives revealed? (Bullet points)
### 4. 🚀 The Path (Now What?)
Define the strategic direction. What are the specific, prioritized next steps? How can this knowledge be applied immediately? (Actionable steps)
## Rules
- Output in the user's specified language.
- Maintain a professional, analytical, yet inspiring tone.
- Focus on the *process* of understanding, not just the result.
- No greetings or meta-commentary.
"""
USER_PROMPT = """
Initiate a Deep Dive into the following content:
**User Context:**
- User: {user_name}
- Time: {current_date_time_str}
- Language: {user_language}
**Content to Analyze:**
```
{long_text_content}
```
Please execute the full thinking chain: Context → Logic → Insight → Path.
"""
# =================================================================
# Premium CSS Design - Deep Dive Theme
# =================================================================
CSS_TEMPLATE = """
.deep-dive {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
color: var(--dd-text-secondary);
}
.dd-header {
background: var(--dd-header-gradient);
padding: 40px 32px;
color: white;
position: relative;
}
.dd-header-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 100px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 16px;
}
.dd-title {
font-size: 2rem;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.02em;
}
.dd-meta {
display: flex;
gap: 20px;
font-size: 0.85rem;
opacity: 0.7;
}
.dd-body {
padding: 32px;
display: flex;
flex-direction: column;
gap: 40px;
position: relative;
background: var(--dd-bg-primary);
}
/* The Thinking Line */
.dd-body::before {
content: '';
position: absolute;
left: 52px;
top: 40px;
bottom: 40px;
width: 2px;
background: var(--dd-border);
z-index: 0;
}
.dd-step {
position: relative;
z-index: 1;
display: flex;
gap: 24px;
}
.dd-step-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--dd-bg-primary);
border: 2px solid var(--dd-border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
transition: all 0.3s ease;
}
.dd-step:hover .dd-step-icon {
border-color: var(--dd-accent);
transform: scale(1.1);
}
.dd-step-content {
flex: 1;
}
.dd-step-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--dd-accent);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 4px;
}
.dd-step-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--dd-text-primary);
margin: 0 0 16px 0;
}
.dd-text {
line-height: 1.7;
font-size: 1rem;
}
.dd-text p { margin-bottom: 16px; }
.dd-text p:last-child { margin-bottom: 0; }
.dd-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
}
.dd-list-item {
background: var(--dd-bg-secondary);
padding: 16px 20px;
border-radius: 12px;
border-left: 4px solid var(--dd-border);
transition: all 0.2s ease;
}
.dd-list-item:hover {
background: var(--dd-bg-tertiary);
border-left-color: var(--dd-accent);
transform: translateX(4px);
}
.dd-list-item strong {
color: var(--dd-text-primary);
display: block;
margin-bottom: 4px;
}
.dd-path-item {
background: var(--dd-accent-soft);
border-left-color: var(--dd-accent);
}
.dd-footer {
padding: 24px 32px;
background: var(--dd-bg-secondary);
border-top: 1px solid var(--dd-border);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--dd-text-dim);
}
.dd-tag {
padding: 2px 8px;
background: var(--dd-bg-tertiary);
border-radius: 4px;
font-weight: 600;
}
.dd-text code,
.dd-list-item code {
background: var(--dd-code-bg);
color: var(--dd-text-primary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 0.85em;
}
.dd-list-item em {
font-style: italic;
color: var(--dd-text-dim);
}
"""
CONTENT_TEMPLATE = """
<div class="deep-dive">
<div class="dd-header">
<div class="dd-header-badge">Thinking Process</div>
<h1 class="dd-title">Deep Dive Analysis</h1>
<div class="dd-meta">
<span>👤 {user_name}</span>
<span>📅 {current_date_time_str}</span>
<span>📊 {word_count} words</span>
</div>
</div>
<div class="dd-body">
<!-- Step 1: Context -->
<div class="dd-step">
<div class="dd-step-icon">🔍</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 01</div>
<h2 class="dd-step-title">The Context</h2>
<div class="dd-text">{context_html}</div>
</div>
</div>
<!-- Step 2: Logic -->
<div class="dd-step">
<div class="dd-step-icon">🧠</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 02</div>
<h2 class="dd-step-title">The Logic</h2>
<div class="dd-text">{logic_html}</div>
</div>
</div>
<!-- Step 3: Insight -->
<div class="dd-step">
<div class="dd-step-icon">💎</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 03</div>
<h2 class="dd-step-title">The Insight</h2>
<div class="dd-text">{insight_html}</div>
</div>
</div>
<!-- Step 4: Path -->
<div class="dd-step">
<div class="dd-step-icon">🚀</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 04</div>
<h2 class="dd-step-title">The Path</h2>
<div class="dd-text">{path_html}</div>
</div>
</div>
</div>
<div class="dd-footer">
<span>Deep Dive Engine v1.0</span>
<span><span class="dd-tag">AI-Powered</span></span>
</div>
</div>
"""
class Action:
class Valves(BaseModel):
SHOW_STATUS: bool = Field(
default=True,
description="Whether to show operation status updates.",
)
MODEL_ID: str = Field(
default="",
description="LLM Model ID for analysis. Empty = use current model.",
)
MIN_TEXT_LENGTH: int = Field(
default=200,
description="Minimum text length for deep dive (chars).",
)
CLEAR_PREVIOUS_HTML: bool = Field(
default=True,
description="Whether to clear previous plugin results.",
)
MESSAGE_COUNT: int = Field(
default=1,
description="Number of recent messages to analyze.",
)
def __init__(self):
self.valves = self.Valves()
def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
"""Safely extracts user context information."""
if isinstance(__user__, (list, tuple)):
user_data = __user__[0] if __user__ else {}
elif isinstance(__user__, dict):
user_data = __user__
else:
user_data = {}
return {
"user_id": user_data.get("id", "unknown_user"),
"user_name": user_data.get("name", "User"),
"user_language": user_data.get("language", "en-US"),
}
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
"""Parse LLM output and convert to styled HTML."""
# Extract sections using flexible regex
context_match = re.search(
r"###\s*1\.\s*🔍?\s*The Context\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
logic_match = re.search(
r"###\s*2\.\s*🧠?\s*The Logic\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
insight_match = re.search(
r"###\s*3\.\s*💎?\s*The Insight\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
path_match = re.search(
r"###\s*4\.\s*🚀?\s*The Path\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
# Fallback if numbering is different
if not context_match:
context_match = re.search(
r"###\s*🔍?\s*The Context.*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not logic_match:
logic_match = re.search(
r"###\s*🧠?\s*The Logic.*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not insight_match:
insight_match = re.search(
r"###\s*💎?\s*The Insight.*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not path_match:
path_match = re.search(
r"###\s*🚀?\s*The Path.*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
context_md = (
context_match.group(1 if context_match.lastindex == 1 else 2).strip()
if context_match
else ""
)
logic_md = (
logic_match.group(1 if logic_match.lastindex == 1 else 2).strip()
if logic_match
else ""
)
insight_md = (
insight_match.group(1 if insight_match.lastindex == 1 else 2).strip()
if insight_match
else ""
)
path_md = (
path_match.group(1 if path_match.lastindex == 1 else 2).strip()
if path_match
else ""
)
if not any([context_md, logic_md, insight_md, path_md]):
context_md = llm_output.strip()
logger.warning("LLM output did not follow format. Using as context.")
md_extensions = ["nl2br"]
context_html = (
markdown.markdown(context_md, extensions=md_extensions)
if context_md
else '<p class="dd-no-content">No context extracted.</p>'
)
logic_html = (
self._process_list_items(logic_md, "logic")
if logic_md
else '<p class="dd-no-content">No logic deconstructed.</p>'
)
insight_html = (
self._process_list_items(insight_md, "insight")
if insight_md
else '<p class="dd-no-content">No insights found.</p>'
)
path_html = (
self._process_list_items(path_md, "path")
if path_md
else '<p class="dd-no-content">No path defined.</p>'
)
return {
"context_html": context_html,
"logic_html": logic_html,
"insight_html": insight_html,
"path_html": path_html,
}
def _process_list_items(self, md_content: str, section_type: str) -> str:
"""Convert markdown list to styled HTML cards with full markdown support."""
lines = md_content.strip().split("\n")
items = []
current_paragraph = []
for line in lines:
line = line.strip()
# Check for list item (bullet or numbered)
bullet_match = re.match(r"^[-*]\s+(.+)$", line)
numbered_match = re.match(r"^\d+\.\s+(.+)$", line)
if bullet_match or numbered_match:
# Flush any accumulated paragraph
if current_paragraph:
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"<p>{para_html}</p>")
current_paragraph = []
# Extract the list item content
text = (
bullet_match.group(1) if bullet_match else numbered_match.group(1)
)
# Handle bold title pattern: **Title:** Description or **Title**: Description
title_match = re.match(r"\*\*(.+?)\*\*[:\s]*(.*)$", text)
if title_match:
title = self._convert_inline_markdown(title_match.group(1))
desc = self._convert_inline_markdown(title_match.group(2).strip())
path_class = "dd-path-item" if section_type == "path" else ""
item_html = f'<div class="dd-list-item {path_class}"><strong>{title}</strong>{desc}</div>'
else:
text_html = self._convert_inline_markdown(text)
path_class = "dd-path-item" if section_type == "path" else ""
item_html = (
f'<div class="dd-list-item {path_class}">{text_html}</div>'
)
items.append(item_html)
elif line and not line.startswith("#"):
# Accumulate paragraph text
current_paragraph.append(line)
elif not line and current_paragraph:
# Empty line ends paragraph
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"<p>{para_html}</p>")
current_paragraph = []
# Flush remaining paragraph
if current_paragraph:
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"<p>{para_html}</p>")
if items:
return f'<div class="dd-list">{" ".join(items)}</div>'
return f'<p class="dd-no-content">No items found.</p>'
def _convert_inline_markdown(self, text: str) -> str:
"""Convert inline markdown (bold, italic, code) to HTML."""
# Convert inline code: `code` -> <code>code</code>
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
# Convert bold: **text** -> <strong>text</strong>
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
# Convert italic: *text* -> <em>text</em> (but not inside **)
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
return text
async def _emit_status(
self,
emitter: Optional[Callable[[Any], Awaitable[None]]],
description: str,
done: bool = False,
):
"""Emits a status update event."""
if self.valves.SHOW_STATUS and emitter:
await emitter(
{"type": "status", "data": {"description": description, "done": done}}
)
async def _emit_notification(
self,
emitter: Optional[Callable[[Any], Awaitable[None]]],
content: str,
ntype: str = "info",
):
"""Emits a notification event."""
if emitter:
await emitter(
{"type": "notification", "data": {"type": ntype, "content": content}}
)
def _remove_existing_html(self, content: str) -> str:
"""Removes existing plugin-generated HTML."""
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
return re.sub(pattern, "", content).strip()
def _extract_text_content(self, content) -> str:
"""Extract text from message content."""
if isinstance(content, str):
return content
elif isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, str):
text_parts.append(item)
return "\n".join(text_parts)
return str(content) if content else ""
def _merge_html(
self,
existing_html: str,
new_content: str,
new_styles: str = "",
user_language: str = "en-US",
) -> str:
"""Merges new content into HTML container."""
if "<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html:
base_html = re.sub(r"^```html\s*", "", existing_html)
base_html = re.sub(r"\s*```$", "", base_html)
else:
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
wrapped = f'<div class="plugin-item">\n{new_content}\n</div>'
if new_styles:
base_html = base_html.replace(
"/* STYLES_INSERTION_POINT */",
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
)
base_html = base_html.replace(
"<!-- CONTENT_INSERTION_POINT -->",
f"{wrapped}\n<!-- CONTENT_INSERTION_POINT -->",
)
return base_html.strip()
def _build_content_html(self, context: dict) -> str:
"""Build content HTML."""
html = CONTENT_TEMPLATE
for key, value in context.items():
html = html.replace(f"{{{key}}}", str(value))
return html
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: Deep Dive v1.0.0 started")
user_ctx = self._get_user_context(__user__)
user_id = user_ctx["user_id"]
user_name = user_ctx["user_name"]
user_language = user_ctx["user_language"]
now = datetime.now()
current_date_time_str = now.strftime("%b %d, %Y %H:%M")
original_content = ""
try:
messages = body.get("messages", [])
if not messages:
raise ValueError("No messages found.")
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
recent_messages = messages[-message_count:]
aggregated_parts = []
for msg in recent_messages:
text = self._extract_text_content(msg.get("content"))
if text:
aggregated_parts.append(text)
if not aggregated_parts:
raise ValueError("No text content found.")
original_content = "\n\n---\n\n".join(aggregated_parts)
word_count = len(original_content.split())
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
msg = f"Content too brief ({len(original_content)} chars). Deep Dive requires at least {self.valves.MIN_TEXT_LENGTH} chars for meaningful analysis."
await self._emit_notification(__event_emitter__, msg, "warning")
return {"messages": [{"role": "assistant", "content": f"⚠️ {msg}"}]}
await self._emit_notification(
__event_emitter__, "🌊 Initiating Deep Dive thinking process...", "info"
)
await self._emit_status(
__event_emitter__, "🌊 Deep Dive: Analyzing Context & Logic...", False
)
prompt = USER_PROMPT.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
user_language=user_language,
long_text_content=original_content,
)
model = self.valves.MODEL_ID or body.get("model")
payload = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"User not found: {user_id}")
response = await generate_chat_completion(__request__, payload, user_obj)
llm_output = response["choices"][0]["message"]["content"]
processed = self._process_llm_output(llm_output)
context = {
"user_name": user_name,
"current_date_time_str": current_date_time_str,
"word_count": word_count,
**processed,
}
content_html = self._build_content_html(context)
# Handle existing HTML
existing = ""
match = re.search(
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
original_content,
)
if match:
existing = match.group(1)
if self.valves.CLEAR_PREVIOUS_HTML or not existing:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE, user_language
)
else:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
existing, content_html, CSS_TEMPLATE, user_language
)
body["messages"][-1][
"content"
] = f"{original_content}\n\n```html\n{final_html}\n```"
await self._emit_status(__event_emitter__, "🌊 Deep Dive complete!", True)
await self._emit_notification(
__event_emitter__,
f"🌊 Deep Dive complete, {user_name}! Thinking chain generated.",
"success",
)
except Exception as e:
logger.error(f"Deep Dive Error: {e}", exc_info=True)
body["messages"][-1][
"content"
] = f"{original_content}\n\n❌ **Error:** {str(e)}"
await self._emit_status(__event_emitter__, "Deep Dive failed.", True)
await self._emit_notification(
__event_emitter__, f"Error: {str(e)}", "error"
)
return body

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

View File

@@ -0,0 +1,876 @@
"""
title: 精读
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 1.0.0
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiA3djE0Ii8+PHBhdGggZD0iTTMgMThhMSAxIDAgMCAxLTEtMVY0YTEgMSAwIDAgMSAxLTFoNWE0IDQgMCAwIDEgNCA0IDQgNCAwIDAgMSA0LTRoNWExIDEgMCAwIDEgMSAxdjEzYTEgMSAwIDAgMS0xIDFoLTZhMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAtMy0zeiIvPjxwYXRoIGQ9Ik02IDEyaDIiLz48cGF0aCBkPSJNMTYgMTJoMiIvPjwvc3ZnPg==
requirements: markdown
description: 全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
"""
# Standard library imports
import re
import logging
from typing import Optional, Dict, Any, Callable, Awaitable
from datetime import datetime
# Third-party imports
from pydantic import BaseModel, Field
from fastapi import Request
import markdown
# OpenWebUI imports
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
# Logging setup
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# =================================================================
# HTML 模板 - 过程导向设计,支持主题自适应
# =================================================================
HTML_WRAPPER_TEMPLATE = """
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
<!DOCTYPE html>
<html lang="{user_language}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--dd-bg-primary: #ffffff;
--dd-bg-secondary: #f8fafc;
--dd-bg-tertiary: #f1f5f9;
--dd-text-primary: #0f172a;
--dd-text-secondary: #334155;
--dd-text-dim: #64748b;
--dd-border: #e2e8f0;
--dd-accent: #3b82f6;
--dd-accent-soft: #eff6ff;
--dd-header-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
--dd-shadow: 0 10px 40px rgba(0,0,0,0.06);
--dd-code-bg: #f1f5f9;
}
.theme-dark {
--dd-bg-primary: #1e293b;
--dd-bg-secondary: #0f172a;
--dd-bg-tertiary: #334155;
--dd-text-primary: #f1f5f9;
--dd-text-secondary: #e2e8f0;
--dd-text-dim: #94a3b8;
--dd-border: #475569;
--dd-accent: #60a5fa;
--dd-accent-soft: rgba(59, 130, 246, 0.15);
--dd-header-gradient: linear-gradient(135deg, #0f172a 0%, #1e1e2e 100%);
--dd-shadow: 0 10px 40px rgba(0,0,0,0.3);
--dd-code-bg: #334155;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 10px;
background-color: transparent;
}
#main-container {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.plugin-item {
background: var(--dd-bg-primary);
border-radius: 24px;
box-shadow: var(--dd-shadow);
overflow: hidden;
border: 1px solid var(--dd-border);
}
/* STYLES_INSERTION_POINT */
</style>
</head>
<body>
<div id="main-container">
<!-- CONTENT_INSERTION_POINT -->
</div>
<!-- SCRIPTS_INSERTION_POINT -->
<script>
(function() {
const parseColorLuma = (colorStr) => {
if (!colorStr) return null;
let m = colorStr.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);
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
m = colorStr.match(/rgba?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/i);
if (m) {
const r = parseInt(m[1], 10);
const g = parseInt(m[2], 10);
const b = parseInt(m[3], 10);
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
return null;
};
const getThemeFromMeta = (doc) => {
const metas = Array.from((doc || document).querySelectorAll('meta[name="theme-color"]'));
if (!metas.length) return null;
const color = metas[metas.length - 1].content.trim();
const luma = parseColorLuma(color);
if (luma === null) return null;
return luma < 0.5 ? 'dark' : 'light';
};
const getParentDocumentSafe = () => {
try {
if (!window.parent || window.parent === window) return null;
const pDoc = window.parent.document;
void pDoc.title;
return pDoc;
} catch (err) { return null; }
};
const getThemeFromParentClass = () => {
try {
if (!window.parent || window.parent === window) return null;
const pDoc = window.parent.document;
const html = pDoc.documentElement;
const body = pDoc.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';
return null;
} catch (err) { return null; }
};
const setTheme = () => {
const parentDoc = getParentDocumentSafe();
const metaTheme = parentDoc ? getThemeFromMeta(parentDoc) : null;
const parentClassTheme = getThemeFromParentClass();
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const chosen = metaTheme || parentClassTheme || (prefersDark ? 'dark' : 'light');
document.documentElement.classList.toggle('theme-dark', chosen === 'dark');
};
setTheme();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', setTheme);
}
})();
</script>
</body>
</html>
"""
# =================================================================
# LLM 提示词 - 深度下潜思维链
# =================================================================
SYSTEM_PROMPT = """
你是一位“深度下潜 (Deep Dive)”分析专家。你的目标是引导用户完成一个全面的思维过程,从表面理解深入到战略行动。
## 思维结构 (严格遵守)
你必须从以下四个维度剖析输入内容:
### 1. 🔍 The Context (全景)
提供一个高层级的全景视图。内容是关于什么的核心情境、背景或正在解决的问题是什么2-3 段话)
### 2. 🧠 The Logic (脉络)
解构底层结构。论点是如何构建的?其中的推理逻辑、隐藏假设或起作用的思维模型是什么?(列表形式)
### 3. 💎 The Insight (洞察)
提取非显性的价值。有哪些“原来如此”的时刻?揭示了哪些深层含义、盲点或独特视角?(列表形式)
### 4. 🚀 The Path (路径)
定义战略方向。具体的、按优先级排列的下一步行动是什么?如何立即应用这些知识?(可执行步骤)
## 规则
- 使用用户指定的语言输出。
- 保持专业、分析性且富有启发性的语调。
- 聚焦于“理解的过程”,而不仅仅是结果。
- 不要包含寒暄或元对话。
"""
USER_PROMPT = """
对以下内容发起“深度下潜”:
**用户上下文:**
- 用户:{user_name}
- 时间:{current_date_time_str}
- 语言:{user_language}
**待分析内容:**
```
{long_text_content}
```
请执行完整的思维链:全景 (Context) → 脉络 (Logic) → 洞察 (Insight) → 路径 (Path)。
"""
# =================================================================
# 现代 CSS 设计 - 深度下潜主题
# =================================================================
CSS_TEMPLATE = """
.deep-dive {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
color: var(--dd-text-secondary);
}
.dd-header {
background: var(--dd-header-gradient);
padding: 40px 32px;
color: white;
position: relative;
}
.dd-header-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 100px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 16px;
}
.dd-title {
font-size: 2rem;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.02em;
}
.dd-meta {
display: flex;
gap: 20px;
font-size: 0.85rem;
opacity: 0.7;
}
.dd-body {
padding: 32px;
display: flex;
flex-direction: column;
gap: 40px;
position: relative;
background: var(--dd-bg-primary);
}
/* 思维导火索 */
.dd-body::before {
content: '';
position: absolute;
left: 52px;
top: 40px;
bottom: 40px;
width: 2px;
background: var(--dd-border);
z-index: 0;
}
.dd-step {
position: relative;
z-index: 1;
display: flex;
gap: 24px;
}
.dd-step-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--dd-bg-primary);
border: 2px solid var(--dd-border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
transition: all 0.3s ease;
}
.dd-step:hover .dd-step-icon {
border-color: var(--dd-accent);
transform: scale(1.1);
}
.dd-step-content {
flex: 1;
}
.dd-step-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--dd-accent);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 4px;
}
.dd-step-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--dd-text-primary);
margin: 0 0 16px 0;
}
.dd-text {
line-height: 1.7;
font-size: 1rem;
}
.dd-text p { margin-bottom: 16px; }
.dd-text p:last-child { margin-bottom: 0; }
.dd-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
}
.dd-list-item {
background: var(--dd-bg-secondary);
padding: 16px 20px;
border-radius: 12px;
border-left: 4px solid var(--dd-border);
transition: all 0.2s ease;
}
.dd-list-item:hover {
background: var(--dd-bg-tertiary);
border-left-color: var(--dd-accent);
transform: translateX(4px);
}
.dd-list-item strong {
color: var(--dd-text-primary);
display: block;
margin-bottom: 4px;
}
.dd-path-item {
background: var(--dd-accent-soft);
border-left-color: var(--dd-accent);
}
.dd-footer {
padding: 24px 32px;
background: var(--dd-bg-secondary);
border-top: 1px solid var(--dd-border);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--dd-text-dim);
}
.dd-tag {
padding: 2px 8px;
background: var(--dd-bg-tertiary);
border-radius: 4px;
font-weight: 600;
}
.dd-text code,
.dd-list-item code {
background: var(--dd-code-bg);
color: var(--dd-text-primary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 0.85em;
}
.dd-list-item em {
font-style: italic;
color: var(--dd-text-dim);
}
"""
CONTENT_TEMPLATE = """
<div class="deep-dive">
<div class="dd-header">
<div class="dd-header-badge">思维过程</div>
<h1 class="dd-title">精读分析报告</h1>
<div class="dd-meta">
<span>👤 {user_name}</span>
<span>📅 {current_date_time_str}</span>
<span>📊 {word_count} 字</span>
</div>
</div>
<div class="dd-body">
<!-- 第一步:全景 -->
<div class="dd-step">
<div class="dd-step-icon">🔍</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 01</div>
<h2 class="dd-step-title">全景 (The Context)</h2>
<div class="dd-text">{context_html}</div>
</div>
</div>
<!-- 第二步:脉络 -->
<div class="dd-step">
<div class="dd-step-icon">🧠</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 02</div>
<h2 class="dd-step-title">脉络 (The Logic)</h2>
<div class="dd-text">{logic_html}</div>
</div>
</div>
<!-- 第三步:洞察 -->
<div class="dd-step">
<div class="dd-step-icon">💎</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 03</div>
<h2 class="dd-step-title">洞察 (The Insight)</h2>
<div class="dd-text">{insight_html}</div>
</div>
</div>
<!-- 第四步:路径 -->
<div class="dd-step">
<div class="dd-step-icon">🚀</div>
<div class="dd-step-content">
<div class="dd-step-label">Phase 04</div>
<h2 class="dd-step-title">路径 (The Path)</h2>
<div class="dd-text">{path_html}</div>
</div>
</div>
</div>
<div class="dd-footer">
<span>Deep Dive Engine v1.0</span>
<span><span class="dd-tag">AI 驱动分析</span></span>
</div>
</div>
"""
class Action:
class Valves(BaseModel):
SHOW_STATUS: bool = Field(
default=True,
description="是否显示操作状态更新。",
)
MODEL_ID: str = Field(
default="",
description="用于分析的 LLM 模型 ID。留空则使用当前模型。",
)
MIN_TEXT_LENGTH: int = Field(
default=200,
description="深度下潜所需的最小文本长度(字符)。",
)
CLEAR_PREVIOUS_HTML: bool = Field(
default=True,
description="是否清除之前的插件结果。",
)
MESSAGE_COUNT: int = Field(
default=1,
description="要分析的最近消息数量。",
)
def __init__(self):
self.valves = self.Valves()
def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
"""安全提取用户上下文信息。"""
if isinstance(__user__, (list, tuple)):
user_data = __user__[0] if __user__ else {}
elif isinstance(__user__, dict):
user_data = __user__
else:
user_data = {}
return {
"user_id": user_data.get("id", "unknown_user"),
"user_name": user_data.get("name", "用户"),
"user_language": user_data.get("language", "zh-CN"),
}
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
"""解析 LLM 输出并转换为样式化 HTML。"""
# 使用灵活的正则提取各部分
context_match = re.search(
r"###\s*1\.\s*🔍?\s*(?:全景|The Context)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
logic_match = re.search(
r"###\s*2\.\s*🧠?\s*(?:脉络|The Logic)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
insight_match = re.search(
r"###\s*3\.\s*💎?\s*(?:洞察|The Insight)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
path_match = re.search(
r"###\s*4\.\s*🚀?\s*(?:路径|The Path)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
# 兜底正则
if not context_match:
context_match = re.search(
r"###\s*🔍?\s*(?:全景|The Context).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not logic_match:
logic_match = re.search(
r"###\s*🧠?\s*(?:脉络|The Logic).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not insight_match:
insight_match = re.search(
r"###\s*💎?\s*(?:洞察|The Insight).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
if not path_match:
path_match = re.search(
r"###\s*🚀?\s*(?:路径|The Path).*?\n(.*?)(?=\n###|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
context_md = (
context_match.group(context_match.lastindex).strip()
if context_match
else ""
)
logic_md = (
logic_match.group(logic_match.lastindex).strip() if logic_match else ""
)
insight_md = (
insight_match.group(insight_match.lastindex).strip()
if insight_match
else ""
)
path_md = path_match.group(path_match.lastindex).strip() if path_match else ""
if not any([context_md, logic_md, insight_md, path_md]):
context_md = llm_output.strip()
logger.warning("LLM 输出未遵循格式,将作为全景处理。")
md_extensions = ["nl2br"]
context_html = (
markdown.markdown(context_md, extensions=md_extensions)
if context_md
else '<p class="dd-no-content">未能提取全景信息。</p>'
)
logic_html = (
self._process_list_items(logic_md, "logic")
if logic_md
else '<p class="dd-no-content">未能解构脉络。</p>'
)
insight_html = (
self._process_list_items(insight_md, "insight")
if insight_md
else '<p class="dd-no-content">未能发现洞察。</p>'
)
path_html = (
self._process_list_items(path_md, "path")
if path_md
else '<p class="dd-no-content">未能定义路径。</p>'
)
return {
"context_html": context_html,
"logic_html": logic_html,
"insight_html": insight_html,
"path_html": path_html,
}
def _process_list_items(self, md_content: str, section_type: str) -> str:
"""将 markdown 列表转换为样式化卡片,支持完整的 markdown 格式。"""
lines = md_content.strip().split("\n")
items = []
current_paragraph = []
for line in lines:
line = line.strip()
# 检查列表项(无序或有序)
bullet_match = re.match(r"^[-*]\s+(.+)$", line)
numbered_match = re.match(r"^\d+\.\s+(.+)$", line)
if bullet_match or numbered_match:
# 清空累积的段落
if current_paragraph:
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"<p>{para_html}</p>")
current_paragraph = []
# 提取列表项内容
text = (
bullet_match.group(1) if bullet_match else numbered_match.group(1)
)
# 处理粗体标题模式:**标题:** 描述 或 **标题**: 描述
title_match = re.match(r"\*\*(.+?)\*\*[:\s]*(.*)$", text)
if title_match:
title = self._convert_inline_markdown(title_match.group(1))
desc = self._convert_inline_markdown(title_match.group(2).strip())
path_class = "dd-path-item" if section_type == "path" else ""
item_html = f'<div class="dd-list-item {path_class}"><strong>{title}</strong>{desc}</div>'
else:
text_html = self._convert_inline_markdown(text)
path_class = "dd-path-item" if section_type == "path" else ""
item_html = (
f'<div class="dd-list-item {path_class}">{text_html}</div>'
)
items.append(item_html)
elif line and not line.startswith("#"):
# 累积段落文本
current_paragraph.append(line)
elif not line and current_paragraph:
# 空行结束段落
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"<p>{para_html}</p>")
current_paragraph = []
# 清空剩余段落
if current_paragraph:
para_text = " ".join(current_paragraph)
para_html = self._convert_inline_markdown(para_text)
items.append(f"<p>{para_html}</p>")
if items:
return f'<div class="dd-list">{" ".join(items)}</div>'
return f'<p class="dd-no-content">未找到条目。</p>'
def _convert_inline_markdown(self, text: str) -> str:
"""将行内 markdown粗体、斜体、代码转换为 HTML。"""
# 转换行内代码:`code` -> <code>code</code>
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
# 转换粗体:**text** -> <strong>text</strong>
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
# 转换斜体:*text* -> <em>text</em>(但不在 ** 内部)
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
return text
async def _emit_status(
self,
emitter: Optional[Callable[[Any], Awaitable[None]]],
description: str,
done: bool = False,
):
"""发送状态更新事件。"""
if self.valves.SHOW_STATUS and emitter:
await emitter(
{"type": "status", "data": {"description": description, "done": done}}
)
async def _emit_notification(
self,
emitter: Optional[Callable[[Any], Awaitable[None]]],
content: str,
ntype: str = "info",
):
"""发送通知事件。"""
if emitter:
await emitter(
{"type": "notification", "data": {"type": ntype, "content": content}}
)
def _remove_existing_html(self, content: str) -> str:
"""移除已有的插件生成的 HTML。"""
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
return re.sub(pattern, "", content).strip()
def _extract_text_content(self, content) -> str:
"""从消息内容中提取文本。"""
if isinstance(content, str):
return content
elif isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif isinstance(item, str):
text_parts.append(item)
return "\n".join(text_parts)
return str(content) if content else ""
def _merge_html(
self,
existing_html: str,
new_content: str,
new_styles: str = "",
user_language: str = "zh-CN",
) -> str:
"""合并新内容到 HTML 容器。"""
if "<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html:
base_html = re.sub(r"^```html\s*", "", existing_html)
base_html = re.sub(r"\s*```$", "", base_html)
else:
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
wrapped = f'<div class="plugin-item">\n{new_content}\n</div>'
if new_styles:
base_html = base_html.replace(
"/* STYLES_INSERTION_POINT */",
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
)
base_html = base_html.replace(
"<!-- CONTENT_INSERTION_POINT -->",
f"{wrapped}\n<!-- CONTENT_INSERTION_POINT -->",
)
return base_html.strip()
def _build_content_html(self, context: dict) -> str:
"""构建内容 HTML。"""
html = CONTENT_TEMPLATE
for key, value in context.items():
html = html.replace(f"{{{key}}}", str(value))
return html
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: 精读 v1.0.0 启动")
user_ctx = self._get_user_context(__user__)
user_id = user_ctx["user_id"]
user_name = user_ctx["user_name"]
user_language = user_ctx["user_language"]
now = datetime.now()
current_date_time_str = now.strftime("%Y年%m月%d%H:%M")
original_content = ""
try:
messages = body.get("messages", [])
if not messages:
raise ValueError("未找到消息内容。")
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
recent_messages = messages[-message_count:]
aggregated_parts = []
for msg in recent_messages:
text = self._extract_text_content(msg.get("content"))
if text:
aggregated_parts.append(text)
if not aggregated_parts:
raise ValueError("未找到文本内容。")
original_content = "\n\n---\n\n".join(aggregated_parts)
word_count = len(original_content)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
msg = f"内容过短({len(original_content)} 字符)。精读至少需要 {self.valves.MIN_TEXT_LENGTH} 字符才能进行有意义的分析。"
await self._emit_notification(__event_emitter__, msg, "warning")
return {"messages": [{"role": "assistant", "content": f"⚠️ {msg}"}]}
await self._emit_notification(
__event_emitter__, "📖 正在发起精读分析...", "info"
)
await self._emit_status(
__event_emitter__, "📖 精读:正在分析全景与脉络...", False
)
prompt = USER_PROMPT.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
user_language=user_language,
long_text_content=original_content,
)
model = self.valves.MODEL_ID or body.get("model")
payload = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"未找到用户:{user_id}")
response = await generate_chat_completion(__request__, payload, user_obj)
llm_output = response["choices"][0]["message"]["content"]
processed = self._process_llm_output(llm_output)
context = {
"user_name": user_name,
"current_date_time_str": current_date_time_str,
"word_count": word_count,
**processed,
}
content_html = self._build_content_html(context)
# 处理已有 HTML
existing = ""
match = re.search(
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
original_content,
)
if match:
existing = match.group(1)
if self.valves.CLEAR_PREVIOUS_HTML or not existing:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE, user_language
)
else:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
existing, content_html, CSS_TEMPLATE, user_language
)
body["messages"][-1][
"content"
] = f"{original_content}\n\n```html\n{final_html}\n```"
await self._emit_status(__event_emitter__, "📖 精读完成!", True)
await self._emit_notification(
__event_emitter__,
f"📖 精读完成,{user_name}!思维链已生成。",
"success",
)
except Exception as e:
logger.error(f"Deep Dive 错误:{e}", exc_info=True)
body["messages"][-1][
"content"
] = f"{original_content}\n\n❌ **错误:** {str(e)}"
await self._emit_status(__event_emitter__, "精读失败。", True)
await self._emit_notification(__event_emitter__, f"错误:{str(e)}", "error")
return body

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

@@ -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,7 +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 interactive HTML)
-**Image Output Mode**: Generate static SVG images embedded directly in Markdown (**No HTML code output**, cleaner chat history)
---

View File

@@ -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,7 +39,7 @@
-**实时渲染**:在聊天界面中直接渲染思维导图,无需跳转
-**导出功能**:支持 PNG、SVG 代码和 Markdown 源码导出
-**自定义配置**:可配置 LLM 模型、最小文本长度等参数
-**图片输出模式**:生成静态 SVG 图片直接嵌入 Markdown无交互式 HTML
-**图片输出模式**:生成静态 SVG 图片直接嵌入 Markdown**不输出 HTML 代码**,聊天记录更简洁
---

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

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.9.1
openwebui_id: 8d4b097b-219b-4dd2-b509-05fbe6388335
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
"""

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,47 @@
"""
Fetch remote plugin versions from OpenWebUI Community
获取远程插件版本信息
"""
import json
import os
import sys
# Add current directory to path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from openwebui_community_client import get_client
def main():
try:
client = get_client()
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
print("Fetching remote plugins from OpenWebUI Community...")
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:
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

@@ -0,0 +1,374 @@
"""
OpenWebUI Community Client
统一封装所有与 OpenWebUI 官方社区 (openwebui.com) 的 API 交互。
功能:
- 获取用户发布的插件/帖子
- 更新插件内容和元数据
- 版本比较
- 同步插件 ID
使用方法:
from openwebui_community_client import OpenWebUICommunityClient
client = OpenWebUICommunityClient(api_key="your_api_key")
posts = client.get_all_posts()
"""
import os
import re
import json
import base64
import requests
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, List, Any, Tuple
# 北京时区 (UTC+8)
BEIJING_TZ = timezone(timedelta(hours=8))
class OpenWebUICommunityClient:
"""OpenWebUI 官方社区 API 客户端"""
BASE_URL = "https://api.openwebui.com/api/v1"
def __init__(self, api_key: str, user_id: Optional[str] = None):
"""
初始化客户端
Args:
api_key: OpenWebUI API Key (JWT Token)
user_id: 用户 ID如果为 None 则从 token 中解析
"""
self.api_key = api_key
self.user_id = user_id or self._parse_user_id_from_token(api_key)
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def _parse_user_id_from_token(self, token: str) -> Optional[str]:
"""从 JWT Token 中解析用户 ID"""
try:
parts = token.split(".")
if len(parts) >= 2:
payload = parts[1]
# 添加 padding
padding = 4 - len(payload) % 4
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
data = json.loads(decoded)
return data.get("id") or data.get("sub")
except Exception:
pass
return None
# ========== 帖子/插件获取 ==========
def get_user_posts(self, sort: str = "new", page: int = 1) -> List[Dict]:
"""
获取用户发布的帖子列表
Args:
sort: 排序方式 (new/top/hot)
page: 页码
Returns:
帖子列表
"""
url = f"{self.BASE_URL}/posts/user/{self.user_id}?sort={sort}&page={page}"
response = requests.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
def get_all_posts(self, sort: str = "new") -> List[Dict]:
"""获取所有帖子(自动分页)"""
all_posts = []
page = 1
while True:
posts = self.get_user_posts(sort=sort, page=page)
if not posts:
break
all_posts.extend(posts)
page += 1
return all_posts
def get_post(self, post_id: str) -> Optional[Dict]:
"""
获取单个帖子详情
Args:
post_id: 帖子 ID
Returns:
帖子数据,如果不存在返回 None
"""
try:
url = f"{self.BASE_URL}/posts/{post_id}"
response = requests.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return None
raise
# ========== 帖子/插件更新 ==========
def update_post(self, post_id: str, post_data: Dict) -> bool:
"""
更新帖子
Args:
post_id: 帖子 ID
post_data: 完整的帖子数据
Returns:
是否成功
"""
url = f"{self.BASE_URL}/posts/{post_id}/update"
response = requests.post(url, headers=self.headers, json=post_data)
response.raise_for_status()
return True
def update_plugin(
self,
post_id: str,
source_code: str,
readme_content: Optional[str] = None,
metadata: Optional[Dict] = None,
) -> bool:
"""
更新插件(代码 + README + 元数据)
Args:
post_id: 帖子 ID
source_code: 插件源代码
readme_content: README 内容(用于社区页面展示)
metadata: 插件元数据title, version, description 等)
Returns:
是否成功
"""
post_data = self.get_post(post_id)
if not post_data:
return False
# 确保结构存在
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"] = {}
# 更新源代码
post_data["data"]["function"]["content"] = source_code
# 更新 README社区页面展示内容
if readme_content:
post_data["content"] = readme_content
# 更新元数据
if metadata:
post_data["data"]["function"]["meta"]["manifest"].update(metadata)
if "title" in metadata:
post_data["title"] = metadata["title"]
post_data["data"]["function"]["name"] = metadata["title"]
if "description" in metadata:
post_data["data"]["function"]["meta"]["description"] = metadata[
"description"
]
return self.update_post(post_id, post_data)
# ========== 版本比较 ==========
def get_remote_version(self, post_id: str) -> Optional[str]:
"""
获取远程插件版本
Args:
post_id: 帖子 ID
Returns:
版本号,如果不存在返回 None
"""
post_data = self.get_post(post_id)
if not post_data:
return None
return (
post_data.get("data", {})
.get("function", {})
.get("meta", {})
.get("manifest", {})
.get("version")
)
def version_needs_update(self, post_id: str, local_version: str) -> bool:
"""
检查是否需要更新
Args:
post_id: 帖子 ID
local_version: 本地版本号
Returns:
如果本地版本与远程不同,返回 True
"""
remote_version = self.get_remote_version(post_id)
if not remote_version:
return True # 远程不存在,需要更新
return local_version != remote_version
# ========== 插件发布 ==========
def publish_plugin_from_file(
self, file_path: str, force: bool = False
) -> Tuple[bool, str]:
"""
从文件发布插件
Args:
file_path: 插件文件路径
force: 是否强制更新(忽略版本检查)
Returns:
(是否成功, 消息)
"""
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
metadata = self._parse_frontmatter(content)
if not metadata:
return False, "No frontmatter found"
post_id = metadata.get("openwebui_id") or metadata.get("post_id")
if not post_id:
return False, "No openwebui_id found"
local_version = metadata.get("version")
# 版本检查
if not force and local_version:
if not self.version_needs_update(post_id, local_version):
return True, f"Skipped: version {local_version} matches remote"
# 查找 README
readme_content = self._find_readme(file_path)
# 更新
success = self.update_plugin(
post_id=post_id,
source_code=content,
readme_content=readme_content or metadata.get("description", ""),
metadata=metadata,
)
if success:
return True, f"Updated to version {local_version}"
return False, "Update failed"
def _parse_frontmatter(self, content: str) -> Dict[str, str]:
"""解析插件文件的 frontmatter"""
match = re.search(r'^\s*"""\n(.*?)\n"""', content, re.DOTALL)
if not match:
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 _find_readme(self, plugin_file_path: str) -> Optional[str]:
"""查找插件对应的 README 文件"""
plugin_dir = os.path.dirname(plugin_file_path)
base_name = os.path.basename(plugin_file_path).lower()
# 确定优先顺序
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):
with open(readme_path, "r", encoding="utf-8") as f:
return f.read()
return None
# ========== 统计功能 ==========
def generate_stats(self, posts: List[Dict]) -> Dict:
"""
生成统计数据
Args:
posts: 帖子列表
Returns:
统计数据字典
"""
stats = {
"total_posts": len(posts),
"total_downloads": 0,
"total_likes": 0,
"posts_by_type": {},
"posts_detail": [],
"generated_at": datetime.now(BEIJING_TZ).isoformat(),
}
for post in posts:
downloads = post.get("downloadCount", 0)
likes = post.get("likeCount", 0)
post_type = post.get("type", "unknown")
stats["total_downloads"] += downloads
stats["total_likes"] += likes
stats["posts_by_type"][post_type] = (
stats["posts_by_type"].get(post_type, 0) + 1
)
stats["posts_detail"].append(
{
"id": post.get("id"),
"title": post.get("title"),
"type": post_type,
"downloads": downloads,
"likes": likes,
"created_at": post.get("createdAt"),
"updated_at": post.get("updatedAt"),
}
)
# 按下载量排序
stats["posts_detail"].sort(key=lambda x: x["downloads"], reverse=True)
return stats
# 便捷函数
def get_client(api_key: Optional[str] = None) -> OpenWebUICommunityClient:
"""
获取客户端实例
Args:
api_key: API Key如果为 None 则从环境变量获取
Returns:
OpenWebUICommunityClient 实例
"""
key = api_key or os.environ.get("OPENWEBUI_API_KEY")
if not key:
raise ValueError("OPENWEBUI_API_KEY not set")
return OpenWebUICommunityClient(key)

View File

@@ -339,8 +339,8 @@ class OpenWebUIStats:
stats: 统计数据
lang: 语言 ("zh" 中文, "en" 英文)
"""
# 获取 Top 5 插件
top_plugins = stats["posts"][:5]
# 获取 Top 6 插件
top_plugins = stats["posts"][:6]
# 中英文文本
texts = {
@@ -349,17 +349,17 @@ class OpenWebUIStats:
"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')}",
"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)

89
scripts/publish_plugin.py Normal file
View File

@@ -0,0 +1,89 @@
"""
Publish plugins to OpenWebUI Community
使用 OpenWebUICommunityClient 发布插件到官方社区
用法:
python scripts/publish_plugin.py # 只更新有版本变化的插件
python scripts/publish_plugin.py --force # 强制更新所有插件
"""
import os
import sys
import re
import argparse
# Add current directory to path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from openwebui_community_client import OpenWebUICommunityClient, get_client
def find_plugins_with_id(plugins_dir: str) -> list:
"""查找所有带 openwebui_id 的插件文件"""
plugins = []
for root, _, files in os.walk(plugins_dir):
for file in files:
if file.endswith(".py"):
file_path = os.path.join(root, file)
with open(file_path, "r", encoding="utf-8") as f:
content = f.read(2000) # 只读前 2000 字符检查 ID
id_match = re.search(
r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content
)
if id_match:
plugins.append(
{"file_path": file_path, "post_id": id_match.group(1).strip()}
)
return plugins
def main():
parser = argparse.ArgumentParser(description="Publish plugins to OpenWebUI Market")
parser.add_argument(
"--force", action="store_true", help="Force update even if version matches"
)
args = parser.parse_args()
try:
client = get_client()
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
plugins_dir = os.path.join(base_dir, "plugins")
plugins = find_plugins_with_id(plugins_dir)
print(f"Found {len(plugins)} plugins with OpenWebUI ID.\n")
updated = 0
skipped = 0
failed = 0
for plugin in plugins:
file_path = plugin["file_path"]
file_name = os.path.basename(file_path)
post_id = plugin["post_id"]
print(f"Processing {file_name} (ID: {post_id})...")
success, message = client.publish_plugin_from_file(file_path, force=args.force)
if success:
if "Skipped" in message:
print(f" ⏭️ {message}")
skipped += 1
else:
print(f"{message}")
updated += 1
else:
print(f"{message}")
failed += 1
print(f"\n{'='*50}")
print(f"Finished: {updated} updated, {skipped} skipped, {failed} failed")
if __name__ == "__main__":
main()

138
scripts/sync_plugin_ids.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Sync OpenWebUI Post IDs to local plugin files
同步远程插件 ID 到本地文件
"""
import os
import sys
import re
import difflib
# Add current directory to path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from openwebui_community_client import get_client
try:
from extract_plugin_versions import scan_plugins_directory
except ImportError:
print("Error: extract_plugin_versions.py 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():
try:
client = get_client()
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
print("Fetching remote posts from OpenWebUI Community...")
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()