Compare commits

...

120 Commits

Author SHA1 Message Date
fujie
06fdfee182 Update context enhancement filter 2026-01-10 18:47:35 +08:00
fujie
7085e794a3 Update Async Context Compression to v1.1.1: Add frontend debug logging and optimize token calculation 2026-01-10 18:47:35 +08:00
github-actions[bot]
a9cae535eb chore: update community stats 2026-01-10 2026-01-10 09:08:08 +00:00
github-actions[bot]
bdbd0d98be chore: update community stats 2026-01-10 2026-01-10 08:24:17 +00:00
fujie
51612ea783 Fix AttributeError in stats script: handle NoneType data field 2026-01-10 16:19:44 +08:00
fujie
baf364a85f Fix mkdocs build warnings: remove references to missing summary.md 2026-01-10 16:09:42 +08:00
fujie
f78e703a99 Fix Mermaid syntax normalization: preserve quoted strings and prevent false positives 2026-01-10 16:07:19 +08:00
fujie
aabb24c9cd docs: update READMEs for markdown normalizer 2026-01-10 15:53:36 +08:00
fujie
ef34cc326c feat: enhance markdown normalizer with mermaid fix and frontend logging 2026-01-10 15:45:20 +08:00
fujie
5fa56ba88d docs: add frontend console debugging guide and mermaid syntax standards 2026-01-10 15:41:17 +08:00
github-actions[bot]
b71df8ef43 chore: update community stats 2026-01-09 2026-01-09 12:14:41 +00:00
fujie
8c6fe6784e chore: only commit stats when points change 2026-01-08 23:21:04 +08:00
github-actions[bot]
29fa5bae29 chore: update community stats 2026-01-08 2026-01-08 15:10:19 +00:00
github-actions[bot]
dab465d924 chore: update community stats 2026-01-08 2026-01-08 14:43:27 +00:00
fujie
77c0defe93 feat: smart commit for stats - only commit when data actually changes
- Keep detailed stats tables in README
- Compare downloads/posts/upvotes before committing
- Skip commit if no actual data change (only time updated)
2026-01-08 22:35:53 +08:00
fujie
80cf2b5a52 feat: switch to dynamic badges - no more stats commits
- Replace README stats tables with Shields.io dynamic badges
- Badges data stored in GitHub Gist (ID: 7beb87fdc36bf10408282b1db495fe55)
- Workflow only uploads to Gist, never commits to main branch
- Stats refresh hourly via GitHub Actions
2026-01-08 22:33:46 +08:00
fujie
96638d8092 feat: smart commit for community-stats - only commit when data changes
- Add generate_shields_endpoints() for dynamic badges
- Update workflow to check for significant changes before commit
- Support uploading badge JSON to GitHub Gist
- Reduce unnecessary commits from hourly to only when data changes
2026-01-08 22:29:02 +08:00
github-actions[bot]
21ad55ae55 chore: update community stats 2026-01-08 2026-01-08 14:10:16 +00:00
github-actions[bot]
530a6cd463 chore: update community stats 2026-01-08 2026-01-08 13:20:58 +00:00
github-actions[bot]
8615773b67 chore: update community stats 2026-01-08 2026-01-08 12:15:27 +00:00
github-actions[bot]
16eaec64b7 chore: update community stats 2026-01-08 2026-01-08 11:09:14 +00:00
github-actions[bot]
8558077dfe chore: update community stats 2026-01-08 2026-01-08 10:10:15 +00:00
github-actions[bot]
a15353ea52 chore: update community stats 2026-01-08 2026-01-08 09:12:19 +00:00
github-actions[bot]
5b44e3e688 chore: update community stats 2026-01-08 2026-01-08 08:12:10 +00:00
github-actions[bot]
a4b3628e01 chore: update community stats 2026-01-08 2026-01-08 07:11:35 +00:00
github-actions[bot]
bbb7db3878 chore: update community stats 2026-01-08 2026-01-08 06:13:29 +00:00
github-actions[bot]
dec2bbb4bf chore: update community stats 2026-01-08 2026-01-08 05:11:27 +00:00
github-actions[bot]
6a241b0ae0 chore: update community stats 2026-01-08 2026-01-08 04:22:44 +00:00
github-actions[bot]
51c53e0ed0 chore: update community stats 2026-01-08 2026-01-08 03:37:14 +00:00
github-actions[bot]
8cb6382e72 chore: update community stats 2026-01-08 2026-01-08 02:45:58 +00:00
github-actions[bot]
5889471e82 chore: update community stats 2026-01-08 2026-01-08 01:36:56 +00:00
fujie
ca2e0b4fba fix: convert media URLs to dict format for create_post API 2026-01-08 08:44:41 +08:00
fujie
10d24fbfa2 debug: add detailed error logging for create_post 2026-01-08 08:41:50 +08:00
fujie
322bd6e167 chore: cleanup legacy plugins and add plugin assets
- Remove deprecated summary plugin (replaced by deep-dive)
- Remove js-render-poc experimental plugin
- Add plugin preview images
- Update publish scripts with create_plugin support
2026-01-08 08:39:21 +08:00
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
fujie
856f76cd27 feat(smart-mind-map): v0.9.1 - Add Image output mode with file upload support 2026-01-06 21:35:36 +08:00
github-actions[bot]
28bb9000d8 📊 更新社区统计数据 2026-01-06 2026-01-06 13:19:34 +00:00
github-actions[bot]
d0b9e46b74 📊 更新社区统计数据 2026-01-06 2026-01-06 12:14:33 +00:00
fujie
a0a4d31715 📝 版本号改为当天发布次数计数 2026-01-06 19:41:22 +08:00
fujie
d5f394f5f1 🐛 修复 README.md 中的重复统计数据 2026-01-06 19:40:21 +08:00
fujie
a477d2baad 🔧 移除时间显示中的时区标注 2026-01-06 19:38:54 +08:00
fujie
8471680efe 时间显示改为北京时间并精确到分钟
- 所有时间戳使用北京时区 (UTC+8)
- 格式从 YYYY-MM-DD 改为 YYYY-MM-DD HH:MM
- 添加 '(北京时间)' 标注
2026-01-06 19:31:18 +08:00
github-actions[bot]
4d44b72dab 📊 更新社区统计数据 2026-01-06 2026-01-06 11:08:23 +00:00
github-actions[bot]
88e14d251a 📊 更新社区统计数据 2026-01-06 2026-01-06 10:09:36 +00:00
github-actions[bot]
e446b6474d 📊 更新社区统计数据 2026-01-06 2026-01-06 09:11:49 +00:00
github-actions[bot]
a2eda6e5af 📊 更新社区统计数据 2026-01-06 2026-01-06 08:12:12 +00:00
github-actions[bot]
fe80c8bee3 📊 更新社区统计数据 2026-01-06 2026-01-06 07:12:40 +00:00
github-actions[bot]
133315d0c6 📊 更新社区统计数据 2026-01-06 2026-01-06 06:13:05 +00:00
github-actions[bot]
3907644282 📊 更新社区统计数据 2026-01-06 2026-01-06 05:11:33 +00:00
github-actions[bot]
d8cde2115f 📊 更新社区统计数据 2026-01-06 2026-01-06 04:22:41 +00:00
github-actions[bot]
0ce63b548f 📊 更新社区统计数据 2026-01-06 2026-01-06 03:37:10 +00:00
github-actions[bot]
06e81c0194 📊 更新社区统计数据 2026-01-06 2026-01-06 02:46:20 +00:00
github-actions[bot]
3763e6501d 📊 更新社区统计数据 2026-01-06 2026-01-06 01:37:32 +00:00
github-actions[bot]
5911f75641 📊 更新社区统计数据 2026-01-06 2026-01-06 00:36:06 +00:00
github-actions[bot]
f936181a37 📊 更新社区统计数据 2026-01-05 2026-01-05 23:08:15 +00:00
github-actions[bot]
a7651f33a4 📊 更新社区统计数据 2026-01-05 2026-01-05 22:08:17 +00:00
github-actions[bot]
45ddf5092b 📊 更新社区统计数据 2026-01-05 2026-01-05 21:08:48 +00:00
github-actions[bot]
61294e90e4 📊 更新社区统计数据 2026-01-05 2026-01-05 20:09:25 +00:00
github-actions[bot]
8619405802 📊 更新社区统计数据 2026-01-05 2026-01-05 19:09:11 +00:00
fujie
f0017ffacd 统计数据更新频率改为每小时 2026-01-06 02:14:26 +08:00
fujie
65fe16e185 🔧 修复数据解析和添加英文报告
- 修正 data 字段解析路径:data.function.meta 而不是 data.meta
- 现在正确显示插件类型 (action/filter) 和版本号
- 添加英文版详细报告 (community-stats.en.md)
- generate_markdown 方法支持中英文切换
2026-01-06 02:02:26 +08:00
fujie
136e7e9021 添加作者统计信息
- README 统计区域新增作者信息:粉丝数、积分、贡献数
- 中英文版本分别使用对应语言的表头
- 从 API 返回的 user 对象中提取用户统计数据
2026-01-06 01:53:03 +08:00
fujie
c1a660a2a1 🔧 修复社区统计功能
- 修正 README 结构:标题 → 语言切换 → 简介 → 统计 → 内容
- 英文版使用英文统计文本,中文版使用中文统计文本
- 修正插件 URL 为 /posts/{slug} 格式
- 清理 README_CN.md 中的重复内容
2026-01-06 01:49:39 +08:00
fujie
53f04debaf 添加 OpenWebUI 社区统计功能
- 新增统计脚本 scripts/openwebui_stats.py
- 新增 GitHub Actions 每日自动更新统计
- README 中英文版添加统计徽章和热门插件 Top 5
- 统计数据输出到 docs/community-stats.md 和 JSON
2026-01-06 01:32:38 +08:00
fujie
4b9790df00 feat: localize parameter names in export_to_word_cn.py and bump to v0.4.1 2026-01-05 23:37:14 +08:00
78 changed files with 10006 additions and 4359 deletions

View File

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

View File

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

78
.github/workflows/community-stats.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
# OpenWebUI 社区统计报告自动生成
# 只在统计数据变化时 commit避免频繁提交
name: Community Stats
on:
# 每小时整点运行
schedule:
- cron: '0 * * * *'
# 手动触发
workflow_dispatch:
permissions:
contents: write
jobs:
update-stats:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install requests python-dotenv
- name: Get previous stats
id: prev_stats
run: |
# 获取当前的 points 用于比较
if [ -f docs/community-stats.json ]; then
OLD_POINTS=$(jq -r '.user.total_points' docs/community-stats.json 2>/dev/null || echo "0")
echo "old_points=$OLD_POINTS" >> $GITHUB_OUTPUT
else
echo "old_points=0" >> $GITHUB_OUTPUT
fi
- name: Generate stats report
env:
OPENWEBUI_API_KEY: ${{ secrets.OPENWEBUI_API_KEY }}
OPENWEBUI_USER_ID: ${{ secrets.OPENWEBUI_USER_ID }}
run: |
python scripts/openwebui_stats.py
- name: Check for significant changes
id: check_changes
run: |
# 获取新的 points
NEW_POINTS=$(jq -r '.user.total_points' docs/community-stats.json 2>/dev/null || echo "0")
echo "📊 Previous points: ${{ steps.prev_stats.outputs.old_points }}"
echo "📊 Current points: $NEW_POINTS"
# 只在 points 变化时才 commit
if [ "$NEW_POINTS" != "${{ steps.prev_stats.outputs.old_points }}" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "✅ Points changed (${{ steps.prev_stats.outputs.old_points }} → $NEW_POINTS), will commit"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "⏭️ Points unchanged, skipping commit"
fi
- 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.zh.md docs/community-stats.md docs/community-stats.json README.md README_CN.md
git diff --staged --quiet || 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

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

View File

@@ -4,7 +4,32 @@ English | [中文](./README_CN.md)
A collection of enhancements, plugins, and prompts for [OpenWebUI](https://github.com/open-webui/open-webui), developed and curated for personal use to extend functionality and improve experience.
[Contributing](./CONTRIBUTING.md)
<!-- STATS_START -->
## 📊 Community Stats
> 🕐 Auto-updated: 2026-01-10 17:08
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **71** | **72** | **21** |
| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |
|:---:|:---:|:---:|:---:|:---:|
| **14** | **1066** | **11486** | **64** | **66** |
### 🔥 Top 6 Popular Plugins
| Rank | Plugin | Downloads | Views |
|:---:|------|:---:|:---:|
| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 341 | 3080 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 181 | 551 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 125 | 1390 |
| 4⃣ | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 116 | 1355 |
| 5⃣ | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | 92 | 1735 |
| 6⃣ | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 87 | 800 |
*See full stats in [Community Stats Report](./docs/community-stats.md)*
<!-- STATS_END -->
## 📦 Project Contents
@@ -77,3 +102,5 @@ If you have great prompts or plugins to share:
1. Fork this repository.
2. Add your files to the appropriate `prompts/` or `plugins/` directory.
3. Submit a Pull Request.
[Contributing](./CONTRIBUTING.md)

View File

@@ -2,7 +2,38 @@
[English](./README.md) | 中文
OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Plugins)
OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词等资源。
<!-- STATS_START -->
## 📊 社区统计
> 🕐 自动更新于 2026-01-10 17:08
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **71** | **72** | **21** |
| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |
|:---:|:---:|:---:|:---:|:---:|
| **14** | **1066** | **11486** | **64** | **66** |
### 🔥 热门插件 Top 6
| 排名 | 插件 | 下载 | 浏览 |
|:---:|------|:---:|:---:|
| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 341 | 3080 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 181 | 551 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 125 | 1390 |
| 4⃣ | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 116 | 1355 |
| 5⃣ | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | 92 | 1735 |
| 6⃣ | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 87 | 800 |
*完整统计请查看 [社区统计报告](./docs/community-stats.zh.md)*
<!-- STATS_END -->
## 📦 项目内容
### 🧩 插件 (Plugins)
位于 `plugins/` 目录,包含各类 Python 编写的功能增强插件:
@@ -19,7 +50,6 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
- **Context Enhancement** (`context_enhancement_filter`): 上下文增强过滤器。
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Gemini Manifold 配套增强。
#### Pipes (模型管道)
- **Gemini Manifold** (`gemini_mainfold`): 集成 Gemini 模型的管道。
@@ -31,40 +61,10 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
位于 `prompts/` 目录,包含精心调优的 System Prompts
- **Coding**: 编程辅助类提示词。
- **Marketing**: 营销文案类提示词。(`/prompts/marketing`): 内容创作、品牌策划、市场分析相关的提示词
- **Marketing**: 营销文案类提示词。
每个提示词都独立保存为 Markdown 文件,可直接在 OpenWebUI 中使用。
### 🔧 插件 (Plugins)
{{ ... }}
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)
## 📦 项目内容
### 🎯 提示词 (Prompts)
位于 `/prompts` 目录,包含针对不同领域的优质提示词模板:
- **编程类** (`/prompts/coding`): 代码生成、调试、优化相关的提示词
- **营销类** (`/prompts/marketing`): 内容创作、品牌策划、市场分析相关的提示词
每个提示词都独立保存为 Markdown 文件,可直接在 OpenWebUI 中使用。
### 🔧 插件 (Plugins)
位于 `/plugins` 目录,提供三种类型的插件扩展:
- **过滤器 (Filters)** - 在用户输入发送给 LLM 前进行处理和优化
- 异步上下文压缩:智能压缩长上下文,优化 token 使用效率
- **动作 (Actions)** - 自定义功能,从聊天中触发
- 思维导图生成:快速生成和导出思维导图
- **管道 (Pipes)** - 对 LLM 响应进行处理和增强
- 各类响应处理和格式化插件
## 📖 开发文档
位于 `docs/zh/` 目录:
@@ -73,7 +73,7 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
- **[从问一个AI到运营一支AI团队](./docs/zh/从问一个AI到运营一支AI团队.md)** - 深度运营经验分享。
更多示例请查看 `docs/examples/` 目录。
## 🚀 快速开始
本项目是一个资源集合,无需安装 Python 环境。你只需要下载对应的文件并导入到你的 OpenWebUI 实例中即可。
@@ -104,3 +104,5 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
1. Fork 本仓库。
2. 将你的文件添加到对应的 `prompts/``plugins/` 目录。
3. 提交 Pull Request。
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)

252
docs/community-stats.json Normal file
View File

@@ -0,0 +1,252 @@
{
"total_posts": 14,
"total_downloads": 1066,
"total_views": 11486,
"total_upvotes": 64,
"total_downvotes": 2,
"total_saves": 66,
"total_comments": 15,
"by_type": {
"unknown": 1,
"action": 11,
"filter": 2
},
"posts": [
{
"title": "Smart Mind Map",
"slug": "turn_any_text_into_beautiful_mind_maps_3094c59a",
"type": "action",
"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": 341,
"views": 3080,
"upvotes": 10,
"saves": 21,
"comments": 10,
"created_at": "2025-12-30",
"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.7",
"author": "Fu-Jie",
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
"downloads": 181,
"views": 551,
"upvotes": 3,
"saves": 4,
"comments": 0,
"created_at": "2025-05-30",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d"
},
{
"title": "Async Context Compression",
"slug": "async_context_compression_b1655bc8",
"type": "filter",
"version": "1.1.0",
"author": "Fu-Jie",
"description": "Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.",
"downloads": 125,
"views": 1390,
"upvotes": 5,
"saves": 9,
"comments": 0,
"created_at": "2025-11-08",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/async_context_compression_b1655bc8"
},
{
"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": 116,
"views": 1355,
"upvotes": 7,
"saves": 9,
"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": 92,
"views": 1735,
"upvotes": 8,
"saves": 6,
"comments": 2,
"created_at": "2025-12-30",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/flash_card_65a2ea8f"
},
{
"title": "Export to Word (Enhanced)",
"slug": "export_to_word_enhanced_formatting_fca6a315",
"type": "action",
"version": "0.4.3",
"author": "Fu-Jie",
"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": 87,
"views": 800,
"upvotes": 5,
"saves": 8,
"comments": 0,
"created_at": "2026-01-03",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315"
},
{
"title": "📊 智能信息图 (AntV Infographic)",
"slug": "智能信息图_e04a48ff",
"type": "action",
"version": "1.4.1",
"author": "jeff",
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
"downloads": 35,
"views": 480,
"upvotes": 3,
"saves": 0,
"comments": 0,
"created_at": "2025-12-28",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
},
{
"title": "导出为 Word (增强版)",
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
"type": "action",
"version": "0.4.3",
"author": "Fu-Jie",
"description": "将对话导出为 Word (.docx),支持 Mermaid 图表 (客户端渲染 SVG+PNG)、LaTeX 数学公式、真实超链接、增强表格格式、代码高亮和引用块。",
"downloads": 31,
"views": 929,
"upvotes": 8,
"saves": 2,
"comments": 1,
"created_at": "2026-01-04",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
},
{
"title": "Deep Dive",
"slug": "deep_dive_c0b846e4",
"type": "action",
"version": "1.0.0",
"author": "Fu-Jie",
"description": "A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.",
"downloads": 22,
"views": 259,
"upvotes": 3,
"saves": 3,
"comments": 0,
"created_at": "2026-01-08",
"updated_at": "2026-01-08",
"url": "https://openwebui.com/posts/deep_dive_c0b846e4"
},
{
"title": "思维导图",
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
"type": "action",
"version": "0.9.1",
"author": "Fu-Jie",
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
"downloads": 17,
"views": 304,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2025-12-31",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b"
},
{
"title": "闪记卡 (Flash Card)",
"slug": "闪记卡生成插件_4a31eac3",
"type": "action",
"version": "0.2.4",
"author": "Fu-Jie",
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
"downloads": 12,
"views": 345,
"upvotes": 4,
"saves": 1,
"comments": 0,
"created_at": "2025-12-30",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/闪记卡生成插件_4a31eac3"
},
{
"title": "异步上下文压缩",
"slug": "异步上下文压缩_5c0617cb",
"type": "filter",
"version": "1.1.0",
"author": "Fu-Jie",
"description": "通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。",
"downloads": 6,
"views": 153,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2025-11-08",
"updated_at": "2026-01-07",
"url": "https://openwebui.com/posts/异步上下文压缩_5c0617cb"
},
{
"title": "精读",
"slug": "精读_99830b0f",
"type": "action",
"version": "1.0.0",
"author": "Fu-Jie",
"description": "全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。",
"downloads": 1,
"views": 86,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2026-01-08",
"updated_at": "2026-01-08",
"url": "https://openwebui.com/posts/精读_99830b0f"
},
{
"title": " 🛠️ Debug Open WebUI Plugins in Your Browser",
"slug": "debug_open_webui_plugins_in_your_browser_81bf7960",
"type": "unknown",
"version": "",
"author": "",
"description": "",
"downloads": 0,
"views": 19,
"upvotes": 2,
"saves": 0,
"comments": 0,
"created_at": "2026-01-10",
"updated_at": "2026-01-10",
"url": "https://openwebui.com/posts/debug_open_webui_plugins_in_your_browser_81bf7960"
}
],
"user": {
"username": "Fu-Jie",
"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": 71,
"following": 2,
"total_points": 72,
"post_points": 62,
"comment_points": 10,
"contributions": 21
}
}

39
docs/community-stats.md Normal file
View File

@@ -0,0 +1,39 @@
# 📊 OpenWebUI Community Stats Report
> 📅 Updated: 2026-01-10 17:08
## 📈 Overview
| Metric | Value |
|------|------|
| 📝 Total Posts | 14 |
| ⬇️ Total Downloads | 1066 |
| 👁️ Total Views | 11486 |
| 👍 Total Upvotes | 64 |
| 💾 Total Saves | 66 |
| 💬 Total Comments | 15 |
## 📂 By Type
- **unknown**: 1
- **action**: 11
- **filter**: 2
## 📋 Posts List
| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 341 | 3080 | 10 | 21 | 2026-01-07 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.7 | 181 | 551 | 3 | 4 | 2026-01-07 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 125 | 1390 | 5 | 9 | 2026-01-07 |
| 4 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.1 | 116 | 1355 | 7 | 9 | 2026-01-07 |
| 5 | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 92 | 1735 | 8 | 6 | 2026-01-07 |
| 6 | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.3 | 87 | 800 | 5 | 8 | 2026-01-07 |
| 7 | [📊 智能信息图 (AntV Infographic)](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.4.1 | 35 | 480 | 3 | 0 | 2026-01-07 |
| 8 | [导出为 Word (增强版)](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.3 | 31 | 929 | 8 | 2 | 2026-01-07 |
| 9 | [Deep Dive](https://openwebui.com/posts/deep_dive_c0b846e4) | action | 1.0.0 | 22 | 259 | 3 | 3 | 2026-01-08 |
| 10 | [思维导图](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.9.1 | 17 | 304 | 2 | 1 | 2026-01-07 |
| 11 | [闪记卡 (Flash Card)](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.4 | 12 | 345 | 4 | 1 | 2026-01-07 |
| 12 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 6 | 153 | 2 | 1 | 2026-01-07 |
| 13 | [精读](https://openwebui.com/posts/精读_99830b0f) | action | 1.0.0 | 1 | 86 | 2 | 1 | 2026-01-08 |
| 14 | [ 🛠️ Debug Open WebUI Plugins in Your Browser](https://openwebui.com/posts/debug_open_webui_plugins_in_your_browser_81bf7960) | unknown | | 0 | 19 | 2 | 0 | 2026-01-10 |

View File

@@ -0,0 +1,39 @@
# 📊 OpenWebUI 社区统计报告
> 📅 更新时间: 2026-01-10 17:08
## 📈 总览
| 指标 | 数值 |
|------|------|
| 📝 发布数量 | 14 |
| ⬇️ 总下载量 | 1066 |
| 👁️ 总浏览量 | 11486 |
| 👍 总点赞数 | 64 |
| 💾 总收藏数 | 66 |
| 💬 总评论数 | 15 |
## 📂 按类型分类
- **unknown**: 1
- **action**: 11
- **filter**: 2
## 📋 发布列表
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 341 | 3080 | 10 | 21 | 2026-01-07 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.7 | 181 | 551 | 3 | 4 | 2026-01-07 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 125 | 1390 | 5 | 9 | 2026-01-07 |
| 4 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.1 | 116 | 1355 | 7 | 9 | 2026-01-07 |
| 5 | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 92 | 1735 | 8 | 6 | 2026-01-07 |
| 6 | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.3 | 87 | 800 | 5 | 8 | 2026-01-07 |
| 7 | [📊 智能信息图 (AntV Infographic)](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.4.1 | 35 | 480 | 3 | 0 | 2026-01-07 |
| 8 | [导出为 Word (增强版)](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.3 | 31 | 929 | 8 | 2 | 2026-01-07 |
| 9 | [Deep Dive](https://openwebui.com/posts/deep_dive_c0b846e4) | action | 1.0.0 | 22 | 259 | 3 | 3 | 2026-01-08 |
| 10 | [思维导图](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.9.1 | 17 | 304 | 2 | 1 | 2026-01-07 |
| 11 | [闪记卡 (Flash Card)](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.4 | 12 | 345 | 4 | 1 | 2026-01-07 |
| 12 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 6 | 153 | 2 | 1 | 2026-01-07 |
| 13 | [精读](https://openwebui.com/posts/精读_99830b0f) | action | 1.0.0 | 1 | 86 | 2 | 1 | 2026-01-08 |
| 14 | [ 🛠️ Debug Open WebUI Plugins in Your Browser](https://openwebui.com/posts/debug_open_webui_plugins_in_your_browser_81bf7960) | unknown | | 0 | 19 | 2 | 0 | 2026-01-10 |

View File

@@ -0,0 +1,150 @@
# 🛠️ Debugging Python Plugins with Frontend Console
When developing plugins for Open WebUI, debugging can be challenging. Standard `print()` statements or server-side logging might not always be accessible, especially in hosted environments or when you want to see the data flow in real-time alongside the UI interactions.
This guide introduces a powerful technique: **Frontend Console Debugging**. By injecting JavaScript from your Python plugin, you can print structured logs directly to the browser's Developer Tools console (F12).
## Why Frontend Debugging?
* **Real-time Feedback**: See logs immediately as actions happen in the browser.
* **Rich Objects**: Inspect complex JSON objects (like `body` or `messages`) interactively, rather than reading massive text dumps.
* **No Server Access Needed**: Debug issues even if you don't have SSH/Console access to the backend server.
* **Clean Output**: Group logs using `console.group()` to keep your console organized.
## The Core Mechanism
Open WebUI plugins (both Actions and Filters) support an event system. We can leverage the `__event_call__` (or sometimes `__event_emitter__`) to send a special event of type `execute`. This tells the frontend to run the provided JavaScript code.
### The Helper Method
To make this easy to use, we recommend adding a helper method `_emit_debug_log` to your plugin class.
```python
import json
from typing import List
async def _emit_debug_log(
self,
__event_call__,
title: str,
data: dict
):
"""
Emit debug log to browser console via JS execution.
Args:
__event_call__: The event callable passed to action/outlet.
title: A title for the log group.
data: A dictionary of data to log.
"""
# 1. Check if debugging is enabled (recommended)
if not getattr(self.valves, "show_debug_log", True) or not __event_call__:
return
try:
# 2. Construct the JavaScript code
# We use an async IIFE (Immediately Invoked Function Expression)
# to ensure a clean scope and support await if needed.
js_code = f"""
(async function() {{
console.group("🛠️ Plugin Debug: {title}");
console.log({json.dumps(data, ensure_ascii=False)});
console.groupEnd();
}})();
"""
# 3. Send the execute event
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
except Exception as e:
print(f"Error emitting debug log: {e}")
```
## Implementation Steps
### 1. Add a Valve for Control
It's best practice to make debugging optional so it doesn't clutter the console for normal users.
```python
from pydantic import BaseModel, Field
class Filter:
class Valves(BaseModel):
show_debug_log: bool = Field(
default=False,
description="Print debug logs to browser console (F12)"
)
def __init__(self):
self.valves = self.Valves()
```
### 2. Inject `__event_call__`
Ensure your `action` (for Actions) or `outlet` (for Filters) method accepts `__event_call__`.
**For Filters (`outlet`):**
```python
async def outlet(
self,
body: dict,
__user__: Optional[dict] = None,
__event_call__=None, # <--- Add this
__metadata__: Optional[dict] = None,
) -> dict:
```
**For Actions (`action`):**
```python
async def action(
self,
body: dict,
__user__=None,
__event_call__=None, # <--- Add this
__request__=None,
):
```
### 3. Call the Helper
Now you can log anything, anywhere in your logic!
```python
# Inside your logic...
new_content = self.process_content(content)
# Log the before and after
await self._emit_debug_log(
__event_call__,
"Content Normalization",
{
"original": content,
"processed": new_content,
"changes": diff_list
}
)
```
## Best Practices
1. **Use `json.dumps`**: Always serialize your Python dictionaries to JSON strings before embedding them in the f-string. This handles escaping quotes and special characters correctly.
2. **Async IIFE**: Wrapping your JS in `(async function() { ... })();` is safer than raw code. It prevents variable collisions with other scripts and allows using `await` inside your debug script if you ever need to check DOM elements.
3. **Check for None**: Always check if `__event_call__` is not None before using it, as it might not be available in all contexts (e.g., when running tests or in older Open WebUI versions).
## Example Output
When enabled, your browser console will show:
```text
> 🛠️ Plugin Debug: Content Normalization
> {original: "...", processed: "...", changes: [...]}
```
You can expand the object to inspect every detail of your data. Happy debugging!

View File

@@ -0,0 +1,64 @@
# Mermaid Syntax Standards & Best Practices
This document summarizes the official syntax standards for Mermaid flowcharts, focusing on node labels, quoting rules, and special character handling. It serves as a reference for the `markdown_normalizer` plugin logic.
## 1. Node Shapes & Syntax
Mermaid supports various node shapes defined by specific wrapping characters.
| Shape | Syntax | Example |
| :--- | :--- | :--- |
| **Rectangle** (Default) | `id[Label]` | `A[Start]` |
| **Rounded** | `id(Label)` | `B(Process)` |
| **Stadium** (Pill) | `id([Label])` | `C([End])` |
| **Subroutine** | `id[[Label]]` | `D[[Subroutine]]` |
| **Cylinder** (Database) | `id[(Label)]` | `E[(Database)]` |
| **Circle** | `id((Label))` | `F((Point))` |
| **Double Circle** | `id(((Label)))` | `G(((Endpoint)))` |
| **Asymmetric** | `id>Label]` | `H>Flag]` |
| **Rhombus** (Decision) | `id{Label}` | `I{Decision}` |
| **Hexagon** | `id{{Label}}` | `J{{Prepare}}` |
| **Parallelogram** | `id[/Label/]` | `K[/Input/]` |
| **Parallelogram Alt** | `id[\Label\]` | `L[\Output\]` |
| **Trapezoid** | `id[/Label\]` | `M[/Trap/]` |
| **Trapezoid Alt** | `id[\Label/]` | `N[\TrapAlt/]` |
## 2. Quoting Rules (Critical)
### Why Quote?
Quoting node labels is **highly recommended** and sometimes **mandatory** to prevent syntax errors.
### Mandatory Quoting Scenarios
You **MUST** enclose labels in double quotes `"` if they contain:
1. **Special Characters**: `()`, `[]`, `{}`, `;`, `"`, etc.
2. **Keywords**: Words like `end`, `subgraph`, etc., if used in specific contexts.
3. **Unicode/Emoji**: While often supported without quotes, quoting ensures consistent rendering across different environments.
4. **Markdown**: If you want to use Markdown formatting (bold, italic) inside a label.
### Best Practice: Always Quote
To ensure robustness, especially when processing LLM-generated content which may contain unpredictable characters, **always enclosing labels in double quotes is the safest strategy**.
**Examples:**
* ❌ Risky: `id(Start: 15:00)` (Colon might be interpreted as style separator)
* ✅ Safe: `id("Start: 15:00")`
* ❌ Broken: `id(Func(x))` (Nested parentheses break parsing)
* ✅ Safe: `id("Func(x)")`
## 3. Escape Characters
Inside a quoted string:
* Double quotes `"` must be escaped as `\"`.
* HTML entities (e.g., `#35;` for `#`) can be used.
## 4. Plugin Logic Verification
The `markdown_normalizer` plugin implements the following logic:
1. **Detection**: Identifies Mermaid node definitions using a comprehensive regex covering all shapes above.
2. **Normalization**:
* Checks if the label is already quoted.
* If **NOT quoted**, it wraps the label in double quotes `""`.
* Escapes any existing double quotes inside the label (`"` -> `\"`).
3. **Shape Preservation**: The regex captures the specific opening and closing delimiters (e.g., `((` and `))`) to ensure the node shape is strictly preserved during normalization.
**Conclusion**: The plugin's behavior of automatically adding quotes to unquoted labels is **fully aligned with Mermaid's official best practices** for robustness and error prevention.

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.0</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.0</span>
<span class="version-badge">v0.4.3</span>
将当前对话导出为完美格式的 Word 文档,支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用资料**以及**增强表格**渲染。
@@ -33,35 +33,35 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
| Valve | 说明 | 默认值 |
| :--- | :--- | :--- |
| `TITLE_SOURCE` | 文档标题/文件名的来源。选项:`chat_title` (对话标题), `ai_generated` (AI 生成), `markdown_title` (Markdown 标题) | `chat_title` |
| `MAX_EMBED_IMAGE_MB` | 嵌入图片的最大大小 (MB)。 | `20` |
| `UI_LANGUAGE` | 界面语言。选项:`en` (英语), `zh` (中文)。 | `zh` |
| `FONT_LATIN` | 英文字体名称。 | `Calibri` |
| `FONT_ASIAN` | 中文字体名称。 | `SimSun` |
| `FONT_CODE` | 代码字体名称。 | `Consolas` |
| `TABLE_HEADER_COLOR` | 表头背景色(十六进制,不带#)。 | `F2F2F2` |
| `TABLE_ZEBRA_COLOR` | 表格隔行背景色(十六进制,不带#)。 | `FBFBFB` |
| `MERMAID_JS_URL` | Mermaid.js 库的 URL。 | `https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js` |
| `MERMAID_JSZIP_URL` | JSZip 库的 URL用于 DOCX 操作)。 | `https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js` |
| `MERMAID_PNG_SCALE` | Mermaid PNG 生成缩放比例(分辨率)。 | `3.0` |
| `MERMAID_DISPLAY_SCALE` | Mermaid 在 Word 中的显示比例(视觉大小)。 | `1.0` |
| `MERMAID_OPTIMIZE_LAYOUT` | 优化 Mermaid 布局: 自动将 LR (左右) 转换为 TD (上下)。 | `False` |
| `MERMAID_BACKGROUND` | Mermaid 图表背景色(如 `white`, `transparent`)。 | `transparent` |
| `MERMAID_CAPTIONS_ENABLE` | 启用/禁用 Mermaid 图表的图注。 | `True` |
| `MERMAID_CAPTION_STYLE` | Mermaid 图注的段落样式名称。 | `Caption` |
| `MERMAID_CAPTION_PREFIX` | 图注前缀(如 '图')。留空则根据语言自动检测。 | `""` |
| `MATH_ENABLE` | 启用 LaTeX 数学公式块转换。 | `True` |
| `MATH_INLINE_DOLLAR_ENABLE` | 启用行内 `$ ... $` 数学公式转换。 | `True` |
| `文档标题来源` | 文档标题/文件名的来源。选项:`chat_title` (对话标题), `ai_generated` (AI 生成), `markdown_title` (Markdown 标题) | `chat_title` |
| `最大嵌入图片大小MB` | 嵌入图片的最大大小 (MB)。 | `20` |
| `界面语言` | 界面语言。选项:`en` (英语), `zh` (中文)。 | `zh` |
| `英文字体` | 英文字体名称。 | `Calibri` |
| `中文字体` | 中文字体名称。 | `SimSun` |
| `代码字体` | 代码字体名称。 | `Consolas` |
| `表头背景色` | 表头背景色(十六进制,不带#)。 | `F2F2F2` |
| `表格隔行背景色` | 表格隔行背景色(十六进制,不带#)。 | `FBFBFB` |
| `Mermaid_JS地址` | Mermaid.js 库的 URL。 | `https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js` |
| `JSZip库地址` | JSZip 库的 URL用于 DOCX 操作)。 | `https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js` |
| `Mermaid_PNG缩放比例` | Mermaid PNG 生成缩放比例(分辨率)。 | `3.0` |
| `Mermaid显示比例` | Mermaid 在 Word 中的显示比例(视觉大小)。 | `1.0` |
| `Mermaid布局优化` | 优化 Mermaid 布局: 自动将 LR (左右) 转换为 TD (上下)。 | `False` |
| `Mermaid背景色` | Mermaid 图表背景色(如 `white`, `transparent`)。 | `transparent` |
| `启用Mermaid图注` | 启用/禁用 Mermaid 图表的图注。 | `True` |
| `Mermaid图注样式` | Mermaid 图注的段落样式名称。 | `Caption` |
| `Mermaid图注前缀` | 图注前缀(如 '图')。留空则根据语言自动检测。 | `""` |
| `启用数学公式` | 启用 LaTeX 数学公式块转换。 | `True` |
| `启用行内公式` | 启用行内 `$ ... $` 数学公式转换。 | `True` |
### 用户级配置 (UserValves)
用户可以在个人设置中覆盖以下配置:
- `TITLE_SOURCE`
- `UI_LANGUAGE`
- `FONT_LATIN`, `FONT_ASIAN`, `FONT_CODE`
- `TABLE_HEADER_COLOR`, `TABLE_ZEBRA_COLOR`
- `MERMAID_...` (部分 Mermaid 设置)
- `MATH_...` (数学公式设置)
- `文档标题来源`
- `界面语言`
- `英文字体`, `中文字体`, `代码字体`
- `表头背景色`, `表格隔行背景色`
- `Mermaid_...` (部分 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.0
**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.0
**版本:** 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

@@ -1,82 +0,0 @@
# Summary
<span class="category-badge action">Action</span>
<span class="version-badge">v0.1.0</span>
Generate concise summaries of long text content with key points extraction.
---
## Overview
The Summary plugin helps you quickly understand long pieces of text by generating concise summaries with extracted key points. It's perfect for:
- Summarizing long articles or documents
- Extracting key points from conversations
- Creating quick overviews of complex topics
## Features
- :material-text-box-search: **Smart Summarization**: AI-powered content analysis
- :material-format-list-bulleted: **Key Points**: Extracted important highlights
- :material-content-copy: **Easy Copy**: One-click copying of summaries
- :material-tune: **Adjustable Length**: Control summary detail level
---
## Installation
1. Download the plugin file: [`summary.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary)
2. Upload to OpenWebUI: **Admin Panel****Settings****Functions**
3. Enable the plugin
---
## Usage
1. Get a long response from the AI or paste long text
2. Click the **Summary** button in the message action bar
3. View the generated summary with key points
---
## Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `summary_length` | string | `"medium"` | Length of summary (short/medium/long) |
| `include_key_points` | boolean | `true` | Extract and list key points |
| `language` | string | `"auto"` | Output language |
---
## Example Output
```markdown
## Summary
This document discusses the implementation of a new feature
for the application, focusing on user experience improvements
and performance optimizations.
### Key Points
- ✅ New user interface design improves accessibility
- ✅ Backend optimizations reduce load times by 40%
- ✅ Mobile responsiveness enhanced
- ✅ Integration with third-party services simplified
```
---
## Requirements
!!! note "Prerequisites"
- OpenWebUI v0.3.0 or later
- Uses the active LLM model for summarization
---
## Source Code
[:fontawesome-brands-github: View on GitHub](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary){ .md-button }

View File

@@ -1,82 +0,0 @@
# Summary摘要
<span class="category-badge action">Action</span>
<span class="version-badge">v0.1.0</span>
为长文本生成简洁摘要,并提取关键要点。
---
## 概览
Summary 插件可以快速理解长文本,生成精炼摘要并列出关键点,适合:
- 总结长文章或文档
- 从对话中提炼要点
- 为复杂主题制作快速概览
## 功能特性
- :material-text-box-search: **智能摘要**AI 驱动的内容分析
- :material-format-list-bulleted: **关键点**:提取重要信息
- :material-content-copy: **便捷复制**:一键复制摘要
- :material-tune: **长度可调**:可选择摘要详略程度
---
## 安装
1. 下载插件文件:[`summary.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary)
2. 上传到 OpenWebUI**Admin Panel** → **Settings****Functions**
3. 启用插件
---
## 使用方法
1. 获取一段较长的 AI 回复或粘贴长文本
2. 点击消息操作栏的 **Summary** 按钮
3. 查看生成的摘要与关键点
---
## 配置项
| 选项 | 类型 | 默认值 | 说明 |
|--------|------|---------|-------------|
| `summary_length` | string | `"medium"` | 摘要长度short/medium/long |
| `include_key_points` | boolean | `true` | 是否提取并列出关键点 |
| `language` | string | `"auto"` | 输出语言 |
---
## 输出示例
```markdown
## Summary
This document discusses the implementation of a new feature
for the application, focusing on user experience improvements
and performance optimizations.
### Key Points
- ✅ New user interface design improves accessibility
- ✅ Backend optimizations reduce load times by 40%
- ✅ Mobile responsiveness enhanced
- ✅ Integration with third-party services simplified
```
---
## 运行要求
!!! note "前置条件"
- OpenWebUI v0.3.0 及以上
- 使用当前会话的 LLM 模型进行摘要
---
## 源码
[:fontawesome-brands-github: 在 GitHub 查看](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary){ .md-button }

View File

@@ -53,7 +53,6 @@ OpenWebUI supports four types of plugins, each serving a different purpose:
| [Knowledge Card](actions/knowledge-card.md) | Action | Create beautiful learning flashcards | 0.2.0 |
| [Export to Excel](actions/export-to-excel.md) | Action | Export chat history to Excel files | 1.0.0 |
| [Export to Word](actions/export-to-word.md) | Action | Export chat content to Word (.docx) with formatting | 0.1.0 |
| [Summary](actions/summary.md) | Action | Text summarization tool | 1.0.0 |
| [Async Context Compression](filters/async-context-compression.md) | Filter | Intelligent context compression | 1.0.0 |
| [Context Enhancement](filters/context-enhancement.md) | Filter | Enhance chat context | 1.0.0 |
| [Gemini Manifold Companion](filters/gemini-manifold-companion.md) | Filter | Companion for Gemini Manifold | 1.0.0 |

View File

@@ -53,7 +53,6 @@ OpenWebUI 支持四种类型的插件,每种都有不同的用途:
| [Knowledge Card知识卡片](actions/knowledge-card.md) | Action | 生成精美学习卡片 | 0.2.0 |
| [Export to Excel导出到 Excel](actions/export-to-excel.md) | Action | 导出聊天记录为 Excel | 1.0.0 |
| [Export to Word导出为 Word](actions/export-to-word.md) | Action | 将聊天内容导出为 Word (.docx) 并保留格式 | 0.1.0 |
| [Summary摘要](actions/summary.md) | Action | 文本摘要工具 | 1.0.0 |
| [Async Context Compression异步上下文压缩](filters/async-context-compression.md) | Filter | 智能上下文压缩 | 1.0.0 |
| [Context Enhancement上下文增强](filters/context-enhancement.md) | Filter | 提升对话上下文 | 1.0.0 |
| [Gemini Manifold Companion](filters/gemini-manifold-companion.md) | Filter | Gemini Manifold 伴侣 | 1.0.0 |

View File

@@ -187,7 +187,6 @@ nav:
- Knowledge Card: plugins/actions/knowledge-card.md
- Export to Excel: plugins/actions/export-to-excel.md
- Export to Word: plugins/actions/export-to-word.md
- Summary: plugins/actions/summary.md
- Filters:
- plugins/filters/index.md
- Async Context Compression: plugins/filters/async-context-compression.md

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,130 +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 引用块渲染为带左侧边框的灰色斜体样式。
- 🌐 **多语言支持**: 正确处理中文和英文文本。
- **TITLE_SOURCE**:选择文档标题/文件名的生成方式。
- `chat_title`:使用对话标题(默认)。
- `ai_generated`:使用 AI 根据内容生成简短标题。
- `markdown_title`:从 Markdown 内容中提取第一个一级或二级标题。
- **MAX_EMBED_IMAGE_MB**:嵌入图片的最大大小 (MB)。默认:`20`
- **UI_LANGUAGE**:界面语言,支持 `en` (英语) 和 `zh` (中文)。默认:`zh`
- **FONT_LATIN**:英文字体名称。默认:`Calibri`
- **FONT_ASIAN**:中文字体名称。默认:`SimSun`
- **FONT_CODE**:代码字体名称。默认:`Consolas`
- **TABLE_HEADER_COLOR**:表头背景色(十六进制,不带#)。默认:`F2F2F2`
- **TABLE_ZEBRA_COLOR**:表格隔行背景色(十六进制,不带#)。默认:`FBFBFB`
- **MERMAID_JS_URL**Mermaid.js 库的 URL。
- **MERMAID_JSZIP_URL**JSZip 库的 URL用于 DOCX 操作)。
- **MERMAID_PNG_SCALE**Mermaid PNG 生成缩放比例(分辨率)。默认:`3.0`
- **MERMAID_DISPLAY_SCALE**Mermaid 在 Word 中的显示比例(视觉大小)。默认:`1.0`
- **MERMAID_OPTIMIZE_LAYOUT**:自动将 LR左右流程图转换为 TD上下。默认`False`
- **MERMAID_BACKGROUND**Mermaid 图表背景色(如 `white`, `transparent`)。默认:`transparent`
- **MERMAID_CAPTIONS_ENABLE**:启用/禁用 Mermaid 图表的图注。默认:`True`
- **MERMAID_CAPTION_STYLE**Mermaid 图注的段落样式名称。默认:`Caption`
- **MERMAID_CAPTION_PREFIX**:图注前缀(如 '图')。留空则根据语言自动检测。
- **MATH_ENABLE**:启用 LaTeX 数学公式块转换(`\[...\]``$$...$$`)。默认:`True`
- **MATH_INLINE_DOLLAR_ENABLE**:启用行内 `$ ... $` 数学公式转换。默认:`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)
所有依赖已在插件文档字符串中声明。
## 📝 更新日志
## 字体配置
### v0.4.3
- **S3 对象存储**: 通过 boto3 直连 S3/MinIO图片获取速度更快。
- **6 级回退机制**: 稳健的文件获取:数据库 → S3 → 本地 → URL → API → 属性。
- **日志优化**: 改进错误提示,便于调试文件访问问题。
- **英文文本**Times New Roman
- **中文文本**:宋体(正文)、黑体(标题)
- **代码**Consolas
## 更新日志
### 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

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.0
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):

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

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.0
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,
@@ -135,147 +146,147 @@ class Action:
}
class Valves(BaseModel):
TITLE_SOURCE: str = Field(
文档标题来源: str = Field(
default="chat_title",
description="Title Source: 'chat_title' (Chat Title), 'ai_generated' (AI Generated), 'markdown_title' (Markdown Title)",
)
MAX_EMBED_IMAGE_MB: int = Field(
最大嵌入图片大小MB: int = Field(
default=20,
description="Maximum image size to embed into DOCX (MB). Applies to data URLs and /api/v1/files/<id>/content images.",
)
# Font configuration
FONT_LATIN: str = Field(
英文字体: str = Field(
default="Calibri",
description="Font for Latin characters (e.g., 'Times New Roman', '', 'Arial')",
)
FONT_ASIAN: str = Field(
中文字体: str = Field(
default="SimSun",
description="Font for Asian characters (e.g., 'SimSun', 'Microsoft YaHei', 'PingFang SC')",
)
FONT_CODE: str = Field(
代码字体: str = Field(
default="Consolas",
description="Font for code blocks and inline code (e.g., 'Consolas', 'Courier New', 'Monaco')",
)
# Table styling
TABLE_HEADER_COLOR: str = Field(
表头背景色: str = Field(
default="F2F2F2",
description="Table header background color (hex, without #)",
)
TABLE_ZEBRA_COLOR: str = Field(
表格隔行背景色: str = Field(
default="FBFBFB",
description="Table zebra stripe background color for alternate rows (hex, without #)",
)
MERMAID_JS_URL: str = Field(
Mermaid_JS地址: str = Field(
default="https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js",
description="Mermaid JS CDN URL",
)
MERMAID_JSZIP_URL: str = Field(
JSZip库地址: str = Field(
default="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js",
description="JSZip CDN URL (DOCX manipulation)",
)
MERMAID_PNG_SCALE: float = Field(
Mermaid_PNG缩放比例: float = Field(
default=3.0,
description="PNG render resolution multiplier (higher = clearer, larger file)",
)
MERMAID_DISPLAY_SCALE: float = Field(
Mermaid显示比例: float = Field(
default=1.0,
description="Diagram width relative to available page width (<=1 recommended)",
)
MERMAID_OPTIMIZE_LAYOUT: bool = Field(
Mermaid布局优化: bool = Field(
default=False,
description="Optimize Mermaid layout: convert LR to TD for graph/flowchart",
)
MERMAID_BACKGROUND: str = Field(
Mermaid背景色: str = Field(
default="",
description="Mermaid background color. Empty = transparent (recommended for Word dark mode). Used only for optional PNG fill.",
)
MERMAID_CAPTIONS_ENABLE: bool = Field(
启用Mermaid图注: bool = Field(
default=True,
description="Add figure captions under Mermaid images/charts",
)
MERMAID_CAPTION_STYLE: str = Field(
Mermaid图注样式: str = Field(
default="Caption",
description="Paragraph style name for Mermaid captions (uses 'Caption' if available, otherwise creates a safe custom style)",
)
MERMAID_CAPTION_PREFIX: str = Field(
Mermaid图注前缀: str = Field(
default="",
description="Caption prefix label (e.g., 'Figure' or ''). Empty = auto-detect based on user language.",
)
MATH_ENABLE: bool = Field(
启用数学公式: bool = Field(
default=True,
description="Enable LaTeX math block conversion (\\[...\\] and $$...$$) into Word equations",
)
MATH_INLINE_DOLLAR_ENABLE: bool = Field(
启用行内公式: bool = Field(
default=True,
description="Enable inline $...$ math conversion into Word equations (conservative parsing to reduce false positives)",
)
# Language configuration
UI_LANGUAGE: str = Field(
界面语言: str = Field(
default="zh",
description="UI language for export messages. Options: 'en' (English), 'zh' (Chinese)",
)
class UserValves(BaseModel):
TITLE_SOURCE: str = Field(
文档标题来源: str = Field(
default="chat_title",
description="Title Source: 'chat_title' (Chat Title), 'ai_generated' (AI Generated), 'markdown_title' (Markdown Title)",
)
UI_LANGUAGE: str = Field(
界面语言: str = Field(
default="zh",
description="UI language for export messages. Options: 'en' (English), 'zh' (Chinese)",
)
FONT_LATIN: str = Field(
英文字体: str = Field(
default="Calibri",
description="Font for Latin characters (e.g., 'Times New Roman', '', 'Arial')",
)
FONT_ASIAN: str = Field(
中文字体: str = Field(
default="SimSun",
description="Font for Asian characters (e.g., 'SimSun', 'Microsoft YaHei', 'PingFang SC')",
)
FONT_CODE: str = Field(
代码字体: str = Field(
default="Consolas",
description="Font for code blocks and inline code (e.g., 'Consolas', 'Courier New', 'Monaco')",
)
TABLE_HEADER_COLOR: str = Field(
表头背景色: str = Field(
default="F2F2F2",
description="Table header background color (hex, without #)",
)
TABLE_ZEBRA_COLOR: str = Field(
表格隔行背景色: str = Field(
default="FBFBFB",
description="Table zebra stripe background color for alternate rows (hex, without #)",
)
MERMAID_PNG_SCALE: float = Field(
Mermaid_PNG缩放比例: float = Field(
default=3.0,
description="PNG render resolution multiplier (higher = clearer, larger file)",
)
MERMAID_DISPLAY_SCALE: float = Field(
Mermaid显示比例: float = Field(
default=1.0,
description="Diagram width relative to available page width (<=1 recommended)",
)
MERMAID_OPTIMIZE_LAYOUT: bool = Field(
Mermaid布局优化: bool = Field(
default=False,
description="Optimize Mermaid layout: convert LR to TD for graph/flowchart",
)
MERMAID_BACKGROUND: str = Field(
Mermaid背景色: str = Field(
default="",
description="Mermaid background color. Empty = transparent (recommended for Word dark mode). Used only for optional PNG fill.",
)
MERMAID_CAPTIONS_ENABLE: bool = Field(
启用Mermaid图注: bool = Field(
default=True,
description="Add figure captions under Mermaid images/charts",
)
MATH_ENABLE: bool = Field(
启用数学公式: bool = Field(
default=True,
description="Enable LaTeX math block conversion (\\\\[...\\\\] and $$...$$) into Word equations",
)
MATH_INLINE_DOLLAR_ENABLE: bool = Field(
启用行内公式: bool = Field(
default=True,
description="Enable inline $...$ math conversion into Word equations (conservative parsing to reduce false positives)",
)
@@ -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')."""
@@ -345,7 +358,23 @@ class Action:
setattr(self.valves, key, value)
# Get user language from Valves configuration
self._user_lang = self._get_lang_key(self.valves.UI_LANGUAGE)
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]
@@ -381,22 +410,22 @@ class Action:
chat_title = await self.fetch_chat_title(chat_id, user_id)
if (
self.valves.TITLE_SOURCE.strip() == "chat_title"
or not self.valves.TITLE_SOURCE.strip()
self.valves.文档标题来源.strip() == "chat_title"
or not self.valves.文档标题来源.strip()
):
title = chat_title
elif self.valves.TITLE_SOURCE.strip() == "markdown_title":
elif self.valves.文档标题来源.strip() == "markdown_title":
title = self.extract_title(message_content)
elif self.valves.TITLE_SOURCE.strip() == "ai_generated":
elif self.valves.文档标题来源.strip() == "ai_generated":
title = await self.generate_title_using_ai(
body, message_content, user_id, __request__
)
# Fallback logic
if not title:
if self.valves.TITLE_SOURCE.strip() != "chat_title" and chat_title:
if self.valves.文档标题来源.strip() != "chat_title" and chat_title:
title = chat_title
elif self.valves.TITLE_SOURCE.strip() != "markdown_title":
elif self.valves.文档标题来源.strip() != "markdown_title":
extracted = self.extract_title(message_content)
if extracted:
title = extracted
@@ -450,11 +479,11 @@ class Action:
(async function() {{
const base64Data = "{base64_blob}";
const filename = "{js_filename}";
const mermaidUrl = "{self.valves.MERMAID_JS_URL}";
const jszipUrl = "{self.valves.MERMAID_JSZIP_URL}";
const pngScale = {float(self.valves.MERMAID_PNG_SCALE)};
const displayScale = {float(self.valves.MERMAID_DISPLAY_SCALE)};
const bgRaw = "{(self.valves.MERMAID_BACKGROUND or '').strip()}";
const mermaidUrl = "{self.valves.Mermaid_JS地址}";
const jszipUrl = "{self.valves.JSZip库地址}";
const pngScale = {float(self.valves.Mermaid_PNG缩放比例)};
const displayScale = {float(self.valves.Mermaid显示比例)};
const bgRaw = "{(self.valves.Mermaid背景色 or '').strip()}";
const bg = (bgRaw || "").trim();
const bgFill = (bg && bg.toLowerCase() !== "transparent") ? bg : "";
const themeBackground = bgFill || "transparent";
@@ -1006,7 +1035,7 @@ class Action:
return cleaned[:50].strip()
def _max_embed_image_bytes(self) -> int:
mb = getattr(self.valves, "MAX_EMBED_IMAGE_MB", 20)
mb = getattr(self.valves, "最大嵌入图片大小MB", 20)
try:
mb_i = int(mb)
except Exception:
@@ -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):
@@ -1149,7 +1344,7 @@ class Action:
self._add_image_placeholder(
paragraph,
alt,
f"invalid data URL or exceeds {self.valves.MAX_EMBED_IMAGE_MB}MB",
f"invalid data URL or exceeds {self.valves.最大嵌入图片大小MB}MB",
)
return
else:
@@ -1239,7 +1434,7 @@ class Action:
line = lines[i]
# Handle display math blocks (\[...\] or $$...$$)
if not in_code_block and self.valves.MATH_ENABLE:
if not in_code_block and self.valves.启用数学公式:
single_line = self._extract_single_line_math(line)
if single_line is not None:
if in_list and list_items:
@@ -1689,12 +1884,12 @@ class Action:
def _insert_mermaid_placeholder(self, doc: Document, mermaid_source: str):
caption_title: Optional[str] = (
self._extract_mermaid_title(mermaid_source)
if self.valves.MERMAID_CAPTIONS_ENABLE
if self.valves.启用Mermaid图注
else None
)
source_for_render = mermaid_source
if self.valves.MERMAID_OPTIMIZE_LAYOUT:
if self.valves.Mermaid布局优化:
source_for_render = re.sub(
r"^(graph|flowchart)\s+LR\b",
r"\1 TD",
@@ -1833,7 +2028,7 @@ class Action:
if self._caption_style_name is not None:
return self._caption_style_name
style_name = (self.valves.MERMAID_CAPTION_STYLE or "").strip()
style_name = (self.valves.Mermaid图注样式 or "").strip()
if style_name == "":
# Empty means: do not apply a caption style.
self._caption_style_name = ""
@@ -1872,11 +2067,11 @@ class Action:
return "Normal"
def _add_mermaid_caption(self, doc: Document, title: Optional[str]):
if not self.valves.MERMAID_CAPTIONS_ENABLE:
if not self.valves.启用Mermaid图注:
return
# Use configured prefix, or auto-detect from user language
prefix = (self.valves.MERMAID_CAPTION_PREFIX or "").strip()
prefix = (self.valves.Mermaid图注前缀 or "").strip()
if prefix == "":
prefix = self._get_msg("figure_prefix")
@@ -1944,10 +2139,10 @@ class Action:
"""Set document default font using configured fonts."""
style = doc.styles["Normal"]
font = style.font
font.name = self.valves.FONT_LATIN
font.name = self.valves.英文字体
font.size = Pt(11)
# Set Asian font
style._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.FONT_ASIAN)
style._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.中文字体)
# Set paragraph format
paragraph_format = style.paragraph_format
@@ -1972,7 +2167,7 @@ class Action:
"""
Parse Markdown inline formatting and add to paragraph.
Supports: bold, italic, inline code, links, strikethrough, auto-link URLs,
and inline LaTeX math \\(...\\) when MATH_ENABLE is on.
and inline LaTeX math \\(...\\) when 启用数学公式 is on.
"""
self._add_inline_segments(
paragraph, text or "", bold=False, italic=False, strike=False
@@ -1997,8 +2192,8 @@ class Action:
if not chunk:
return
run = paragraph.add_run(chunk)
run.font.name = self.valves.FONT_CODE
run._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.FONT_CODE)
run.font.name = self.valves.代码字体
run._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.代码字体)
run.font.size = Pt(10)
shading = OxmlElement("w:shd")
shading.set(qn("w:fill"), "E8E8E8")
@@ -2052,9 +2247,9 @@ class Action:
rPr = OxmlElement("w:rPr")
rFonts = OxmlElement("w:rFonts")
rFonts.set(qn("w:ascii"), self.valves.FONT_CODE)
rFonts.set(qn("w:hAnsi"), self.valves.FONT_CODE)
rFonts.set(qn("w:eastAsia"), self.valves.FONT_CODE)
rFonts.set(qn("w:ascii"), self.valves.代码字体)
rFonts.set(qn("w:hAnsi"), self.valves.代码字体)
rFonts.set(qn("w:eastAsia"), self.valves.代码字体)
rPr.append(rFonts)
sz = OxmlElement("w:sz")
@@ -2190,11 +2385,7 @@ class Action:
continue
# Inline $...$ math (conservative parsing)
if (
text[i] == "$"
and self.valves.MATH_ENABLE
and self.valves.MATH_INLINE_DOLLAR_ENABLE
):
if text[i] == "$" and self.valves.启用数学公式 and self.valves.启用行内公式:
# Avoid treating $$ as inline math here (block math uses $$ on its own line).
if text.startswith("$$", i):
self._add_text_run(paragraph, "$", bold, italic, strike)
@@ -2439,7 +2630,7 @@ class Action:
if not latex:
return
if not self.valves.MATH_ENABLE or not LATEX_MATH_AVAILABLE:
if not self.valves.启用数学公式 or not LATEX_MATH_AVAILABLE:
self._add_text_run(
paragraph, f"\\({latex}\\)", bold=bold, italic=italic, strike=strike
)
@@ -2511,7 +2702,7 @@ class Action:
lang_para.paragraph_format.space_after = Pt(0)
lang_para.paragraph_format.left_indent = Cm(0.5)
lang_run = lang_para.add_run(language.upper())
lang_run.font.name = self.valves.FONT_CODE
lang_run.font.name = self.valves.代码字体
lang_run.font.size = Pt(8)
lang_run.font.color.rgb = RGBColor(100, 100, 100)
lang_run.font.bold = True
@@ -2540,8 +2731,8 @@ class Action:
if not token_value:
continue
run = paragraph.add_run(token_value)
run.font.name = self.valves.FONT_CODE
run._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.FONT_CODE)
run.font.name = self.valves.代码字体
run._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.代码字体)
run.font.size = Pt(10)
# Apply color
@@ -2555,8 +2746,8 @@ class Action:
else:
# No syntax highlighting, plain text display
run = paragraph.add_run(code)
run.font.name = self.valves.FONT_CODE
run._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.FONT_CODE)
run.font.name = self.valves.代码字体
run._element.rPr.rFonts.set(qn("w:eastAsia"), self.valves.代码字体)
run.font.size = Pt(10)
def add_table(self, doc: Document, table_lines: List[str]):
@@ -2570,8 +2761,8 @@ class Action:
return c
return default
header_fill = _validate_hex(self.valves.TABLE_HEADER_COLOR, "F2F2F2")
zebra_fill = _validate_hex(self.valves.TABLE_ZEBRA_COLOR, "FBFBFB")
header_fill = _validate_hex(self.valves.表头背景色, "F2F2F2")
zebra_fill = _validate_hex(self.valves.表格隔行背景色, "FBFBFB")
def _split_row(line: str) -> List[str]:
# Keep empty cells, trim surrounding pipes.

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
- 移除输出中的调试信息

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

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

View File

@@ -1,170 +0,0 @@
# Infographic to Markdown
> **Version:** 1.0.0
AI-powered infographic generator that renders SVG on the frontend and embeds it directly into Markdown as a Data URL image.
## Overview
This plugin combines the power of AI text analysis with AntV Infographic visualization to create beautiful infographics that are embedded directly into chat messages as Markdown images.
### How It Works
```
┌─────────────────────────────────────────────────────────────┐
│ Open WebUI Plugin │
├─────────────────────────────────────────────────────────────┤
│ 1. Python Action │
│ ├── Receive message content │
│ ├── Call LLM to generate Infographic syntax │
│ └── Send __event_call__ to execute frontend JS │
├─────────────────────────────────────────────────────────────┤
│ 2. Browser JS (via __event_call__) │
│ ├── Dynamically load AntV Infographic library │
│ ├── Render SVG offscreen │
│ ├── Export to Data URL via toDataURL() │
│ └── Update message content via REST API │
├─────────────────────────────────────────────────────────────┤
│ 3. Markdown Rendering │
│ └── Display ![description](data:image/svg+xml;base64,...) │
└─────────────────────────────────────────────────────────────┘
```
## Features
- 🤖 **AI-Powered**: Automatically analyzes text and selects the best infographic template
- 📊 **Multiple Templates**: Supports 18+ infographic templates (lists, charts, comparisons, etc.)
- 🖼️ **Self-Contained**: SVG/PNG embedded as Data URL, no external dependencies
- 📝 **Markdown Native**: Results are pure Markdown images, compatible everywhere
- 🔄 **API Writeback**: Updates message content via REST API for persistence
## Plugins in This Directory
### 1. `infographic_markdown.py` - Main Plugin ⭐
- **Purpose**: Production use
- **Features**: Full AI + AntV Infographic + Data URL embedding
### 2. `js_render_poc.py` - Proof of Concept
- **Purpose**: Learning and testing
- **Features**: Simple SVG creation demo, `__event_call__` pattern
## Configuration (Valves)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `SHOW_STATUS` | bool | `true` | Show operation status updates |
| `MODEL_ID` | string | `""` | LLM model ID (empty = use current model) |
| `MIN_TEXT_LENGTH` | int | `50` | Minimum text length required |
| `MESSAGE_COUNT` | int | `1` | Number of recent messages to use |
| `SVG_WIDTH` | int | `800` | Width of generated SVG (pixels) |
| `EXPORT_FORMAT` | string | `"svg"` | Export format: `svg` or `png` |
## Supported Templates
| Category | Template | Description |
|----------|----------|-------------|
| List | `list-grid` | Grid cards |
| List | `list-vertical` | Vertical list |
| Tree | `tree-vertical` | Vertical tree |
| Tree | `tree-horizontal` | Horizontal tree |
| Mind Map | `mindmap` | Mind map |
| Process | `sequence-roadmap` | Roadmap |
| Process | `sequence-zigzag` | Zigzag process |
| Relation | `relation-sankey` | Sankey diagram |
| Relation | `relation-circle` | Circular relation |
| Compare | `compare-binary` | Binary comparison |
| Analysis | `compare-swot` | SWOT analysis |
| Quadrant | `quadrant-quarter` | Quadrant chart |
| Chart | `chart-bar` | Bar chart |
| Chart | `chart-column` | Column chart |
| Chart | `chart-line` | Line chart |
| Chart | `chart-pie` | Pie chart |
| Chart | `chart-doughnut` | Doughnut chart |
| Chart | `chart-area` | Area chart |
## Syntax Examples
### Grid List
```infographic
infographic list-grid
data
title Project Overview
items
- label Module A
desc Description of module A
- label Module B
desc Description of module B
```
### Binary Comparison
```infographic
infographic compare-binary
data
title Pros vs Cons
items
- label Pros
children
- label Strong R&D
desc Technology leadership
- label Cons
children
- label Weak brand
desc Insufficient marketing
```
### Bar Chart
```infographic
infographic chart-bar
data
title Quarterly Revenue
items
- label Q1
value 120
- label Q2
value 150
```
## Technical Details
### Data URL Embedding
```javascript
// SVG to Base64 Data URL
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
// Markdown image syntax
const markdownImage = `![description](${dataUri})`;
```
### AntV toDataURL API
```javascript
// Export as SVG (recommended, supports embedded resources)
const svgUrl = await instance.toDataURL({
type: 'svg',
embedResources: true
});
// Export as PNG (more compatible but larger)
const pngUrl = await instance.toDataURL({
type: 'png',
dpr: 2
});
```
## Notes
1. **Browser Compatibility**: Requires modern browsers with ES6+ and Fetch API support
2. **Network Dependency**: First use requires loading AntV library from CDN
3. **Data URL Size**: Base64 encoding increases size by ~33%
4. **Chinese Fonts**: SVG export embeds fonts for correct display
## Related Resources
- [AntV Infographic Documentation](https://infographic.antv.vision/)
- [Infographic API Reference](https://infographic.antv.vision/reference/infographic-api)
- [Infographic Syntax Guide](https://infographic.antv.vision/learn/infographic-syntax)
## License
MIT License

View File

@@ -1,174 +0,0 @@
# 信息图转 Markdown
> **版本:** 1.0.0
AI 驱动的信息图生成器,在前端渲染 SVG 并以 Data URL 图片格式直接嵌入到 Markdown 中。
## 概述
这个插件结合了 AI 文本分析能力和 AntV Infographic 可视化引擎,生成精美的信息图并以 Markdown 图片格式直接嵌入到聊天消息中。
### 工作原理
```
┌─────────────────────────────────────────────────────────────┐
│ Open WebUI 插件 │
├─────────────────────────────────────────────────────────────┤
│ 1. Python Action │
│ ├── 接收消息内容 │
│ ├── 调用 LLM 生成 Infographic 语法 │
│ └── 发送 __event_call__ 执行前端 JS │
├─────────────────────────────────────────────────────────────┤
│ 2. 浏览器 JS (通过 __event_call__) │
│ ├── 动态加载 AntV Infographic 库 │
│ ├── 离屏渲染 SVG │
│ ├── 使用 toDataURL() 导出 Data URL │
│ └── 通过 REST API 更新消息内容 │
├─────────────────────────────────────────────────────────────┤
│ 3. Markdown 渲染 │
│ └── 显示 ![描述](data:image/svg+xml;base64,...) │
└─────────────────────────────────────────────────────────────┘
```
## 功能特点
- 🤖 **AI 驱动**: 自动分析文本并选择最佳的信息图模板
- 📊 **多种模板**: 支持 18+ 种信息图模板(列表、图表、对比等)
- 🖼️ **自包含**: SVG/PNG 以 Data URL 嵌入,无外部依赖
- 📝 **Markdown 原生**: 结果是纯 Markdown 图片,兼容任何平台
- 🔄 **API 回写**: 通过 REST API 更新消息内容实现持久化
## 目录中的插件
### 1. `infographic_markdown.py` - 主插件 ⭐
- **用途**: 生产使用
- **功能**: 完整的 AI + AntV Infographic + Data URL 嵌入
### 2. `infographic_markdown_cn.py` - 主插件(中文版)
- **用途**: 生产使用
- **功能**: 与英文版相同,界面文字为中文
### 3. `js_render_poc.py` - 概念验证
- **用途**: 学习和测试
- **功能**: 简单的 SVG 创建演示,`__event_call__` 模式
## 配置选项 (Valves)
| 参数 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `SHOW_STATUS` | bool | `true` | 是否显示操作状态 |
| `MODEL_ID` | string | `""` | LLM 模型 ID空则使用当前模型 |
| `MIN_TEXT_LENGTH` | int | `50` | 最小文本长度要求 |
| `MESSAGE_COUNT` | int | `1` | 用于生成的最近消息数量 |
| `SVG_WIDTH` | int | `800` | 生成的 SVG 宽度(像素) |
| `EXPORT_FORMAT` | string | `"svg"` | 导出格式:`svg``png` |
## 支持的模板
| 类别 | 模板名称 | 描述 |
|------|----------|------|
| 列表 | `list-grid` | 网格卡片 |
| 列表 | `list-vertical` | 垂直列表 |
| 树形 | `tree-vertical` | 垂直树 |
| 树形 | `tree-horizontal` | 水平树 |
| 思维导图 | `mindmap` | 思维导图 |
| 流程 | `sequence-roadmap` | 路线图 |
| 流程 | `sequence-zigzag` | 折线流程 |
| 关系 | `relation-sankey` | 桑基图 |
| 关系 | `relation-circle` | 圆形关系 |
| 对比 | `compare-binary` | 二元对比 |
| 分析 | `compare-swot` | SWOT 分析 |
| 象限 | `quadrant-quarter` | 四象限图 |
| 图表 | `chart-bar` | 条形图 |
| 图表 | `chart-column` | 柱状图 |
| 图表 | `chart-line` | 折线图 |
| 图表 | `chart-pie` | 饼图 |
| 图表 | `chart-doughnut` | 环形图 |
| 图表 | `chart-area` | 面积图 |
## 语法示例
### 网格列表
```infographic
infographic list-grid
data
title 项目概览
items
- label 模块一
desc 这是第一个模块的描述
- label 模块二
desc 这是第二个模块的描述
```
### 二元对比
```infographic
infographic compare-binary
data
title 优劣对比
items
- label 优势
children
- label 研发能力强
desc 技术领先
- label 劣势
children
- label 品牌曝光不足
desc 营销力度不够
```
### 条形图
```infographic
infographic chart-bar
data
title 季度收入
items
- label Q1
value 120
- label Q2
value 150
```
## 技术细节
### Data URL 嵌入
```javascript
// SVG 转 Base64 Data URL
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
// Markdown 图片语法
const markdownImage = `![描述](${dataUri})`;
```
### AntV toDataURL API
```javascript
// 导出 SVG推荐支持嵌入资源
const svgUrl = await instance.toDataURL({
type: 'svg',
embedResources: true
});
// 导出 PNG更兼容但体积更大
const pngUrl = await instance.toDataURL({
type: 'png',
dpr: 2
});
```
## 注意事项
1. **浏览器兼容性**: 需要现代浏览器支持 ES6+ 和 Fetch API
2. **网络依赖**: 首次使用需要从 CDN 加载 AntV Infographic 库
3. **Data URL 大小**: Base64 编码会增加约 33% 的体积
4. **中文字体**: SVG 导出时会嵌入字体以确保正确显示
## 相关资源
- [AntV Infographic 官方文档](https://infographic.antv.vision/)
- [Infographic API 参考](https://infographic.antv.vision/reference/infographic-api)
- [Infographic 语法规范](https://infographic.antv.vision/learn/infographic-syntax)
## 许可证
MIT License

View File

@@ -1,592 +0,0 @@
"""
title: 📊 Infographic to Markdown
author: Fu-Jie
version: 1.0.0
description: AI生成信息图语法前端渲染SVG并转换为Markdown图片格式嵌入消息。支持AntV Infographic模板。
"""
import time
import json
import logging
import re
from typing import Optional, Callable, Awaitable, Any, Dict
from pydantic import BaseModel, Field
from fastapi import Request
from datetime import datetime
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# =================================================================
# LLM Prompts
# =================================================================
SYSTEM_PROMPT_INFOGRAPHIC = """
You are a professional infographic design expert who can analyze user-provided text content and convert it into AntV Infographic syntax format.
## Infographic Syntax Specification
Infographic syntax is a Mermaid-like declarative syntax for describing infographic templates, data, and themes.
### Syntax Rules
- Entry uses `infographic <template-name>`
- Key-value pairs are separated by spaces, **absolutely NO colons allowed**
- Use two spaces for indentation
- Object arrays use `-` with line breaks
⚠️ **IMPORTANT WARNING: This is NOT YAML format!**
- ❌ Wrong: `children:` `items:` `data:` (with colons)
- ✅ Correct: `children` `items` `data` (without colons)
### Template Library & Selection Guide
Choose the most appropriate template based on the content structure:
#### 1. List & Hierarchy
- **List**: `list-grid` (Grid Cards), `list-vertical` (Vertical List)
- **Tree**: `tree-vertical` (Vertical Tree), `tree-horizontal` (Horizontal Tree)
- **Mindmap**: `mindmap` (Mind Map)
#### 2. Sequence & Relationship
- **Process**: `sequence-roadmap` (Roadmap), `sequence-zigzag` (Zigzag Process)
- **Relationship**: `relation-sankey` (Sankey Diagram), `relation-circle` (Circular)
#### 3. Comparison & Analysis
- **Comparison**: `compare-binary` (Binary Comparison)
- **Analysis**: `compare-swot` (SWOT Analysis), `quadrant-quarter` (Quadrant Chart)
#### 4. Charts & Data
- **Charts**: `chart-bar`, `chart-column`, `chart-line`, `chart-pie`, `chart-doughnut`, `chart-area`
### Data Structure Examples
#### A. Standard List/Tree
```infographic
infographic list-grid
data
title Project Modules
items
- label Module A
desc Description of A
- label Module B
desc Description of B
```
#### B. Binary Comparison
```infographic
infographic compare-binary
data
title Advantages vs Disadvantages
items
- label Advantages
children
- label Strong R&D
desc Leading technology
- label Disadvantages
children
- label Weak brand
desc Insufficient marketing
```
#### C. Charts
```infographic
infographic chart-bar
data
title Quarterly Revenue
items
- label Q1
value 120
- label Q2
value 150
```
### Common Data Fields
- `label`: Main title/label (Required)
- `desc`: Description text (max 30 Chinese chars / 60 English chars for `list-grid`)
- `value`: Numeric value (for charts)
- `children`: Nested items
## Output Requirements
1. **Language**: Output content in the user's language.
2. **Format**: Wrap output in ```infographic ... ```.
3. **No Colons**: Do NOT use colons after keys.
4. **Indentation**: Use 2 spaces.
"""
USER_PROMPT_GENERATE = """
Please analyze the following text content and convert its core information into AntV Infographic syntax format.
---
**User Context:**
User Name: {user_name}
Current Date/Time: {current_date_time_str}
User Language: {user_language}
---
**Text Content:**
{long_text_content}
Please select the most appropriate infographic template based on text characteristics and output standard infographic syntax.
**Important Note:**
- If using `list-grid` format, ensure each card's `desc` description is limited to **maximum 30 Chinese characters** (or **approximately 60 English characters**).
- Descriptions should be concise and highlight key points.
"""
class Action:
class Valves(BaseModel):
SHOW_STATUS: bool = Field(
default=True, description="Show operation status updates in chat interface."
)
MODEL_ID: str = Field(
default="",
description="LLM model ID for text analysis. If empty, uses current conversation model.",
)
MIN_TEXT_LENGTH: int = Field(
default=50,
description="Minimum text length (characters) required for infographic analysis.",
)
MESSAGE_COUNT: int = Field(
default=1,
description="Number of recent messages to use for generation.",
)
SVG_WIDTH: int = Field(
default=800,
description="Width of generated SVG in pixels.",
)
EXPORT_FORMAT: str = Field(
default="svg",
description="Export format: 'svg' or 'png'.",
)
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)
if match:
return match.group(1).strip()
else:
logger.warning("LLM output did not follow expected format, treating entire output as syntax.")
return llm_output.strip()
def _extract_text_content(self, content) -> str:
"""Extract text from message content, supporting multimodal formats"""
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 ""
async def _emit_status(self, emitter, description: str, done: bool = False):
"""Send status update event"""
if self.valves.SHOW_STATUS and emitter:
await emitter(
{"type": "status", "data": {"description": description, "done": done}}
)
def _generate_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
infographic_syntax: str,
svg_width: int,
export_format: str,
) -> str:
"""Generate JavaScript code for frontend SVG rendering"""
# Escape the syntax for JS embedding
syntax_escaped = (
infographic_syntax
.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
# Template mapping (same as infographic.py)
template_mapping_js = """
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'
};
"""
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const svgWidth = {svg_width};
const exportFormat = "{export_format}";
console.log("[Infographic Markdown] Starting render...");
console.log("[Infographic Markdown] chatId:", chatId, "messageId:", messageId);
try {{
// Load AntV Infographic if not loaded
if (typeof AntVInfographic === 'undefined') {{
console.log("[Infographic Markdown] Loading AntV Infographic library...");
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);
}});
console.log("[Infographic Markdown] Library loaded.");
}}
const {{ Infographic }} = AntVInfographic;
// Get infographic syntax
let syntaxContent = `{syntax_escaped}`;
console.log("[Infographic Markdown] Original syntax:", syntaxContent.substring(0, 200) + "...");
// Clean up syntax
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 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')) {{
syntaxContent = 'infographic list-grid\\n' + syntaxContent;
}}
// Apply template mapping
{template_mapping_js}
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
if (regex.test(syntaxContent)) {{
console.log(`[Infographic Markdown] Auto-mapping: ${{key}} -> ${{value}}`);
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
break;
}}
}}
console.log("[Infographic Markdown] Cleaned syntax:", syntaxContent.substring(0, 200) + "...");
// 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;';
document.body.appendChild(container);
// Create and render infographic
const instance = new Infographic({{
container: '#' + container.id,
width: svgWidth,
padding: 24,
}});
console.log("[Infographic Markdown] Rendering infographic...");
instance.render(syntaxContent);
// Wait for render and export
await new Promise(resolve => setTimeout(resolve, 1000));
let dataUrl;
if (exportFormat === 'png') {{
dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }});
}} else {{
dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }});
}}
console.log("[Infographic Markdown] Data URL generated, length:", dataUrl.length);
// Cleanup
instance.destroy();
document.body.removeChild(container);
// Generate markdown image
const markdownImage = `![📊 AI 生成的信息图](${{dataUrl}})`;
// Update message via API
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// Get current message content
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 originalContent = "";
if (chatData.chat && chatData.chat.messages) {{
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
if (targetMsg && targetMsg.content) {{
originalContent = targetMsg.content;
}}
}}
// Remove existing infographic images
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
let cleanedContent = originalContent.replace(infographicPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// Append new image
const newContent = cleanedContent + "\\n\\n" + markdownImage;
// Update message
const updateResponse = 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 }}
}})
}});
if (updateResponse.ok) {{
console.log("[Infographic Markdown] ✅ Message updated successfully!");
}} else {{
console.error("[Infographic Markdown] API error:", updateResponse.status);
}}
}} else {{
console.warn("[Infographic Markdown] ⚠️ Missing chatId or messageId");
}}
}} catch (error) {{
console.error("[Infographic Markdown] Error:", error);
}}
}})();
"""
async def action(
self,
body: dict,
__user__: dict = None,
__event_emitter__=None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None,
__request__: Request = None,
) -> dict:
"""
Generate infographic using AntV and embed as Markdown image.
"""
logger.info("Action: Infographic to Markdown started")
# Get user information
if isinstance(__user__, (list, tuple)):
user_language = __user__[0].get("language", "en") if __user__ else "en"
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user"
elif isinstance(__user__, dict):
user_language = __user__.get("language", "en")
user_name = __user__.get("name", "User")
user_id = __user__.get("id", "unknown_user")
else:
user_language = "en"
user_name = "User"
user_id = "unknown_user"
# Get current time
now = datetime.now()
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
try:
messages = body.get("messages", [])
if not messages:
raise ValueError("No messages available.")
# Get recent messages
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
recent_messages = messages[-message_count:]
# Aggregate content
aggregated_parts = []
for msg in recent_messages:
text_content = self._extract_text_content(msg.get("content"))
if text_content:
aggregated_parts.append(text_content)
if not aggregated_parts:
raise ValueError("No text content found in messages.")
long_text_content = "\n\n---\n\n".join(aggregated_parts)
# Remove existing HTML blocks
parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL)
clean_content = ""
for part in reversed(parts):
if part.strip():
clean_content = part.strip()
break
if not clean_content:
clean_content = long_text_content.strip()
# Check minimum length
if len(clean_content) < self.valves.MIN_TEXT_LENGTH:
await self._emit_status(
__event_emitter__,
f"⚠️ 内容太短 ({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符",
True,
)
return body
await self._emit_status(__event_emitter__, "📊 正在分析内容...", False)
# Generate infographic syntax via LLM
formatted_user_prompt = USER_PROMPT_GENERATE.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
user_language=user_language,
long_text_content=clean_content,
)
target_model = self.valves.MODEL_ID or body.get("model")
llm_payload = {
"model": target_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC},
{"role": "user", "content": formatted_user_prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"Unable to get user object: {user_id}")
await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False)
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
if not llm_response or "choices" not in llm_response or not llm_response["choices"]:
raise ValueError("Invalid LLM response.")
assistant_content = llm_response["choices"][0]["message"]["content"]
infographic_syntax = self._extract_infographic_syntax(assistant_content)
logger.info(f"Generated syntax: {infographic_syntax[:200]}...")
# Extract IDs for API callback
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
unique_id = f"ig_{int(time.time() * 1000)}"
await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False)
# Execute JS to render and embed
if __event_call__:
js_code = self._generate_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
infographic_syntax=infographic_syntax,
svg_width=self.valves.SVG_WIDTH,
export_format=self.valves.EXPORT_FORMAT,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True)
logger.info("Infographic to Markdown completed")
except Exception as e:
error_message = f"Infographic generation failed: {str(e)}"
logger.error(error_message, exc_info=True)
await self._emit_status(__event_emitter__, f"{error_message}", True)
return body

View File

@@ -1,592 +0,0 @@
"""
title: 📊 信息图转 Markdown
author: Fu-Jie
version: 1.0.0
description: AI 生成信息图语法,前端渲染 SVG 并转换为 Markdown 图片格式嵌入消息。支持 AntV Infographic 模板。
"""
import time
import json
import logging
import re
from typing import Optional, Callable, Awaitable, Any, Dict
from pydantic import BaseModel, Field
from fastapi import Request
from datetime import datetime
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# =================================================================
# LLM 提示词
# =================================================================
SYSTEM_PROMPT_INFOGRAPHIC = """
你是一位专业的信息图设计专家,能够分析用户提供的文本内容并将其转换为 AntV Infographic 语法格式。
## 信息图语法规范
信息图语法是一种类似 Mermaid 的声明式语法,用于描述信息图模板、数据和主题。
### 语法规则
- 入口使用 `infographic <模板名>`
- 键值对用空格分隔,**绝对不允许使用冒号**
- 使用两个空格缩进
- 对象数组使用 `-` 加换行
⚠️ **重要警告:这不是 YAML 格式!**
- ❌ 错误:`children:` `items:` `data:`(带冒号)
- ✅ 正确:`children` `items` `data`(不带冒号)
### 模板库与选择指南
根据内容结构选择最合适的模板:
#### 1. 列表与层级
- **列表**`list-grid`(网格卡片)、`list-vertical`(垂直列表)
- **树形**`tree-vertical`(垂直树)、`tree-horizontal`(水平树)
- **思维导图**`mindmap`(思维导图)
#### 2. 序列与关系
- **流程**`sequence-roadmap`(路线图)、`sequence-zigzag`(折线流程)
- **关系**`relation-sankey`(桑基图)、`relation-circle`(圆形关系)
#### 3. 对比与分析
- **对比**`compare-binary`(二元对比)
- **分析**`compare-swot`SWOT 分析)、`quadrant-quarter`(象限图)
#### 4. 图表与数据
- **图表**`chart-bar`、`chart-column`、`chart-line`、`chart-pie`、`chart-doughnut`、`chart-area`
### 数据结构示例
#### A. 标准列表/树形
```infographic
infographic list-grid
data
title 项目模块
items
- label 模块 A
desc 模块 A 的描述
- label 模块 B
desc 模块 B 的描述
```
#### B. 二元对比
```infographic
infographic compare-binary
data
title 优势与劣势
items
- label 优势
children
- label 研发能力强
desc 技术领先
- label 劣势
children
- label 品牌曝光弱
desc 营销不足
```
#### C. 图表
```infographic
infographic chart-bar
data
title 季度收入
items
- label Q1
value 120
- label Q2
value 150
```
### 常用数据字段
- `label`:主标题/标签(必填)
- `desc`:描述文字(`list-grid` 最多 30 个中文字符)
- `value`:数值(用于图表)
- `children`:嵌套项
## 输出要求
1. **语言**:使用用户的语言输出内容。
2. **格式**:用 ```infographic ... ``` 包裹输出。
3. **无冒号**:键后面不要使用冒号。
4. **缩进**:使用 2 个空格。
"""
USER_PROMPT_GENERATE = """
请分析以下文本内容,将其核心信息转换为 AntV Infographic 语法格式。
---
**用户上下文:**
用户名:{user_name}
当前时间:{current_date_time_str}
用户语言:{user_language}
---
**文本内容:**
{long_text_content}
请根据文本特征选择最合适的信息图模板,输出标准的信息图语法。
**重要提示:**
- 如果使用 `list-grid` 格式,确保每个卡片的 `desc` 描述限制在 **最多 30 个中文字符**。
- 描述应简洁,突出重点。
"""
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=50,
description="信息图分析所需的最小文本长度(字符数)。",
)
MESSAGE_COUNT: int = Field(
default=1,
description="用于生成的最近消息数量。",
)
SVG_WIDTH: int = Field(
default=800,
description="生成的 SVG 宽度(像素)。",
)
EXPORT_FORMAT: str = Field(
default="svg",
description="导出格式:'svg''png'",
)
def __init__(self):
self.valves = self.Valves()
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 输出中提取信息图语法"""
match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL)
if match:
return match.group(1).strip()
else:
logger.warning("LLM 输出未遵循预期格式,将整个输出作为语法处理。")
return llm_output.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 ""
async def _emit_status(self, emitter, description: str, done: bool = False):
"""发送状态更新事件"""
if self.valves.SHOW_STATUS and emitter:
await emitter(
{"type": "status", "data": {"description": description, "done": done}}
)
def _generate_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
infographic_syntax: str,
svg_width: int,
export_format: str,
) -> str:
"""生成用于前端 SVG 渲染的 JavaScript 代码"""
# 转义语法以便嵌入 JS
syntax_escaped = (
infographic_syntax
.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
# 模板映射
template_mapping_js = """
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'
};
"""
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const svgWidth = {svg_width};
const exportFormat = "{export_format}";
console.log("[信息图 Markdown] 开始渲染...");
console.log("[信息图 Markdown] chatId:", chatId, "messageId:", messageId);
try {{
// 加载 AntV Infographic如果尚未加载
if (typeof AntVInfographic === 'undefined') {{
console.log("[信息图 Markdown] 正在加载 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);
}});
console.log("[信息图 Markdown] 库加载完成。");
}}
const {{ Infographic }} = AntVInfographic;
// 获取信息图语法
let syntaxContent = `{syntax_escaped}`;
console.log("[信息图 Markdown] 原始语法:", syntaxContent.substring(0, 200) + "...");
// 清理语法
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')) {{
syntaxContent = 'infographic list-grid\\n' + syntaxContent;
}}
// 应用模板映射
{template_mapping_js}
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
if (regex.test(syntaxContent)) {{
console.log(`[信息图 Markdown] 自动映射: ${{key}} -> ${{value}}`);
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
break;
}}
}}
console.log("[信息图 Markdown] 清理后语法:", syntaxContent.substring(0, 200) + "...");
// 创建离屏容器
const container = document.createElement('div');
container.id = 'infographic-offscreen-' + uniqueId;
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;';
document.body.appendChild(container);
// 创建并渲染信息图
const instance = new Infographic({{
container: '#' + container.id,
width: svgWidth,
padding: 24,
}});
console.log("[信息图 Markdown] 正在渲染信息图...");
instance.render(syntaxContent);
// 等待渲染完成并导出
await new Promise(resolve => setTimeout(resolve, 1000));
let dataUrl;
if (exportFormat === 'png') {{
dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }});
}} else {{
dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }});
}}
console.log("[信息图 Markdown] Data URL 已生成,长度:", dataUrl.length);
// 清理
instance.destroy();
document.body.removeChild(container);
// 生成 Markdown 图片
const markdownImage = `![📊 AI 生成的信息图](${{dataUrl}})`;
// 通过 API 更新消息
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// 获取当前消息内容
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 originalContent = "";
if (chatData.chat && chatData.chat.messages) {{
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
if (targetMsg && targetMsg.content) {{
originalContent = targetMsg.content;
}}
}}
// 移除已有的信息图图片
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
let cleanedContent = originalContent.replace(infographicPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// 追加新图片
const newContent = cleanedContent + "\\n\\n" + markdownImage;
// 更新消息
const updateResponse = 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 }}
}})
}});
if (updateResponse.ok) {{
console.log("[信息图 Markdown] ✅ 消息更新成功!");
}} else {{
console.error("[信息图 Markdown] API 错误:", updateResponse.status);
}}
}} else {{
console.warn("[信息图 Markdown] ⚠️ 缺少 chatId 或 messageId");
}}
}} catch (error) {{
console.error("[信息图 Markdown] 错误:", error);
}}
}})();
"""
async def action(
self,
body: dict,
__user__: dict = None,
__event_emitter__=None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None,
__request__: Request = None,
) -> dict:
"""
使用 AntV 生成信息图并作为 Markdown 图片嵌入。
"""
logger.info("动作:信息图转 Markdown 开始")
# 获取用户信息
if isinstance(__user__, (list, tuple)):
user_language = __user__[0].get("language", "zh") if __user__ else "zh"
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user"
elif isinstance(__user__, dict):
user_language = __user__.get("language", "zh")
user_name = __user__.get("name", "用户")
user_id = __user__.get("id", "unknown_user")
else:
user_language = "zh"
user_name = "用户"
user_id = "unknown_user"
# 获取当前时间
now = datetime.now()
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
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_content = self._extract_text_content(msg.get("content"))
if text_content:
aggregated_parts.append(text_content)
if not aggregated_parts:
raise ValueError("消息中未找到文本内容。")
long_text_content = "\n\n---\n\n".join(aggregated_parts)
# 移除已有的 HTML 块
parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL)
clean_content = ""
for part in reversed(parts):
if part.strip():
clean_content = part.strip()
break
if not clean_content:
clean_content = long_text_content.strip()
# 检查最小长度
if len(clean_content) < self.valves.MIN_TEXT_LENGTH:
await self._emit_status(
__event_emitter__,
f"⚠️ 内容太短({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符",
True,
)
return body
await self._emit_status(__event_emitter__, "📊 正在分析内容...", False)
# 通过 LLM 生成信息图语法
formatted_user_prompt = USER_PROMPT_GENERATE.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
user_language=user_language,
long_text_content=clean_content,
)
target_model = self.valves.MODEL_ID or body.get("model")
llm_payload = {
"model": target_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC},
{"role": "user", "content": formatted_user_prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"无法获取用户对象:{user_id}")
await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False)
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
if not llm_response or "choices" not in llm_response or not llm_response["choices"]:
raise ValueError("无效的 LLM 响应。")
assistant_content = llm_response["choices"][0]["message"]["content"]
infographic_syntax = self._extract_infographic_syntax(assistant_content)
logger.info(f"生成的语法:{infographic_syntax[:200]}...")
# 提取 API 回调所需的 ID
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
unique_id = f"ig_{int(time.time() * 1000)}"
await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False)
# 执行 JS 进行渲染和嵌入
if __event_call__:
js_code = self._generate_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
infographic_syntax=infographic_syntax,
svg_width=self.valves.SVG_WIDTH,
export_format=self.valves.EXPORT_FORMAT,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True)
logger.info("信息图转 Markdown 完成")
except Exception as e:
error_message = f"信息图生成失败:{str(e)}"
logger.error(error_message, exc_info=True)
await self._emit_status(__event_emitter__, f"{error_message}", True)
return body

View File

@@ -1,257 +0,0 @@
"""
title: JS Render PoC
author: Fu-Jie
version: 0.6.0
description: Proof of concept for JS rendering + API write-back pattern. JS renders SVG and updates message via API.
"""
import time
import json
import logging
from typing import Optional, Callable, Awaitable, Any
from pydantic import BaseModel, Field
from fastapi import Request
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class Action:
class Valves(BaseModel):
pass
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):
# body["chat_id"] 是 chat_id
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):
# body["id"] 是 message_id
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 ""
async def action(
self,
body: dict,
__user__: dict = None,
__event_emitter__=None,
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
__metadata__: Optional[dict] = None,
__request__: Request = None,
) -> dict:
"""
PoC: Use __event_call__ to execute JS that renders SVG and updates message via API.
"""
# 准备调试数据
body_for_log = {}
for k, v in body.items():
if k == "messages":
body_for_log[k] = f"[{len(v)} messages]"
else:
body_for_log[k] = v
body_json = json.dumps(body_for_log, ensure_ascii=False, default=str)
metadata_json = (
json.dumps(__metadata__, ensure_ascii=False, default=str)
if __metadata__
else "null"
)
# 转义 JSON 中的特殊字符以便嵌入 JS
body_json_escaped = (
body_json.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
)
metadata_json_escaped = (
metadata_json.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
)
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
unique_id = f"poc_{int(time.time() * 1000)}"
if __event_emitter__:
await __event_emitter__(
{
"type": "status",
"data": {"description": "🔄 正在渲染...", "done": False},
}
)
if __event_call__:
await __event_call__(
{
"type": "execute",
"data": {
"code": f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
// ===== DEBUG: 输出 Python 端的数据 =====
console.log("[JS Render PoC] ===== DEBUG INFO (from Python) =====");
console.log("[JS Render PoC] body:", `{body_json_escaped}`);
console.log("[JS Render PoC] __metadata__:", `{metadata_json_escaped}`);
console.log("[JS Render PoC] Extracted: chatId=", chatId, "messageId=", messageId);
console.log("[JS Render PoC] =========================================");
try {{
console.log("[JS Render PoC] Starting SVG render...");
// Create SVG
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "200");
svg.setAttribute("height", "200");
svg.setAttribute("viewBox", "0 0 200 200");
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const gradient = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
gradient.setAttribute("id", "grad-" + uniqueId);
gradient.innerHTML = `
<stop offset="0%" style="stop-color:#1e88e5;stop-opacity:1" />
<stop offset="100%" style="stop-color:#43a047;stop-opacity:1" />
`;
defs.appendChild(gradient);
svg.appendChild(defs);
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "100");
circle.setAttribute("cy", "100");
circle.setAttribute("r", "80");
circle.setAttribute("fill", `url(#grad-${{uniqueId}})`);
svg.appendChild(circle);
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", "100");
text.setAttribute("y", "105");
text.setAttribute("text-anchor", "middle");
text.setAttribute("fill", "white");
text.setAttribute("font-size", "16");
text.setAttribute("font-weight", "bold");
text.textContent = "PoC Success!";
svg.appendChild(text);
// Convert to Base64 Data URI
const svgData = new XMLSerializer().serializeToString(svg);
const base64 = btoa(unescape(encodeURIComponent(svgData)));
const dataUri = "data:image/svg+xml;base64," + base64;
console.log("[JS Render PoC] SVG rendered, data URI length:", dataUri.length);
// Call API - 完全替换方案(更稳定)
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// 1. 获取当前消息内容
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();
console.log("[JS Render PoC] Got chat data");
let originalContent = "";
if (chatData.chat && chatData.chat.messages) {{
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
if (targetMsg && targetMsg.content) {{
originalContent = targetMsg.content;
console.log("[JS Render PoC] Found original content, length:", originalContent.length);
}}
}}
// 2. 移除已存在的 PoC 图片(如果有的话)
// 匹配 ![JS Render PoC 生成的 SVG](data:...) 格式
const pocImagePattern = /\\n*!\\[JS Render PoC[^\\]]*\\]\\(data:image\\/svg\\+xml;base64,[^)]+\\)/g;
let cleanedContent = originalContent.replace(pocImagePattern, "");
// 移除可能残留的多余空行
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
if (cleanedContent !== originalContent) {{
console.log("[JS Render PoC] Removed existing PoC image(s)");
}}
// 3. 添加新的 Markdown 图片
const markdownImage = `![JS Render PoC 生成的 SVG](${{dataUri}})`;
const newContent = cleanedContent + "\\n\\n" + markdownImage;
// 3. 使用 chat:message 完全替换
const updateResponse = 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 }}
}})
}});
if (updateResponse.ok) {{
console.log("[JS Render PoC] ✅ Message updated successfully!");
}} else {{
console.error("[JS Render PoC] API error:", updateResponse.status, await updateResponse.text());
}}
}} else {{
console.warn("[JS Render PoC] ⚠️ Missing chatId or messageId, cannot persist.");
}}
}} catch (error) {{
console.error("[JS Render PoC] Error:", error);
}}
}})();
"""
},
}
)
if __event_emitter__:
await __event_emitter__(
{"type": "status", "data": {"description": "✅ 渲染完成", "done": True}}
)
return body

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

View File

@@ -3,7 +3,8 @@ title: Smart Mind Map
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.8.2
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.
"""
@@ -13,7 +14,7 @@ import os
import re
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Any, Callable, Awaitable, Dict, Optional
from zoneinfo import ZoneInfo
from fastapi import Request
@@ -786,6 +787,10 @@ 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="html",
description="Output mode: 'html' for interactive HTML (default), or 'image' to embed as Markdown image.",
)
def __init__(self):
self.valves = self.Valves()
@@ -814,6 +819,46 @@ class Action:
"user_language": user_data.get("language", "en-US"),
}
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_markdown_syntax(self, llm_output: str) -> str:
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
if match:
@@ -901,14 +946,391 @@ class Action:
return base_html.strip()
def _generate_image_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
markdown_syntax: str,
) -> str:
"""Generate JavaScript code for frontend SVG rendering and image embedding"""
# Escape the syntax for JS embedding
syntax_escaped = (
markdown_syntax.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const defaultWidth = 1200;
const defaultHeight = 800;
// Theme detection - check parent document for OpenWebUI theme
const detectTheme = () => {{
try {{
// 1. Check parent document's html/body class or data-theme
const html = document.documentElement;
const body = document.body;
const htmlClass = html ? html.className : '';
const bodyClass = body ? body.className : '';
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
return 'dark';
}}
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
return 'light';
}}
// 2. Check meta theme-color
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (metas.length > 0) {{
const color = metas[metas.length - 1].content.trim();
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
if (m) {{
const hex = m[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luma < 0.5 ? 'dark' : 'light';
}}
}}
// 3. Check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
return 'dark';
}}
return 'light';
}} catch (e) {{
return 'light';
}}
}};
const currentTheme = detectTheme();
console.log("[MindMap Image] Detected theme:", currentTheme);
// Theme-based colors
const colors = currentTheme === 'dark' ? {{
background: '#1f2937',
text: '#e5e7eb',
link: '#94a3b8',
nodeStroke: '#64748b'
}} : {{
background: '#ffffff',
text: '#1f2937',
link: '#546e7a',
nodeStroke: '#94a3b8'
}};
// Auto-detect chat container width for responsive sizing
let svgWidth = defaultWidth;
let svgHeight = defaultHeight;
const chatContainer = document.getElementById('chat-container');
if (chatContainer) {{
const containerWidth = chatContainer.clientWidth;
if (containerWidth > 100) {{
// Use container width with some padding (90% of container)
svgWidth = Math.floor(containerWidth * 0.9);
// Maintain aspect ratio based on default dimensions
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
console.log("[MindMap Image] Auto-detected container width:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
}}
}}
console.log("[MindMap Image] Starting render...");
console.log("[MindMap Image] chatId:", chatId, "messageId:", messageId);
try {{
// Load D3 if not loaded
if (typeof d3 === 'undefined') {{
console.log("[MindMap Image] Loading D3...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
// Load markmap-lib if not loaded
if (!window.markmap || !window.markmap.Transformer) {{
console.log("[MindMap Image] Loading markmap-lib...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/markmap-lib@0.17';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
// Load markmap-view if not loaded
if (!window.markmap || !window.markmap.Markmap) {{
console.log("[MindMap Image] Loading markmap-view...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/markmap-view@0.17';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
const {{ Transformer, Markmap }} = window.markmap;
// Get markdown syntax
let syntaxContent = `{syntax_escaped}`;
console.log("[MindMap Image] Syntax length:", syntaxContent.length);
// Create offscreen container
const container = document.createElement('div');
container.id = 'mindmap-offscreen-' + uniqueId;
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;';
document.body.appendChild(container);
// Create SVG element
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgEl.setAttribute('width', svgWidth);
svgEl.setAttribute('height', svgHeight);
svgEl.style.width = svgWidth + 'px';
svgEl.style.height = svgHeight + 'px';
svgEl.style.backgroundColor = colors.background;
container.appendChild(svgEl);
// Transform markdown to tree
const transformer = new Transformer();
const {{ root }} = transformer.transform(syntaxContent);
// Create markmap instance
const options = {{
autoFit: true,
initialExpandLevel: Infinity,
zoom: false,
pan: false
}};
console.log("[MindMap Image] Rendering markmap...");
const markmapInstance = Markmap.create(svgEl, options, root);
// Wait for render to complete
await new Promise(resolve => setTimeout(resolve, 1500));
markmapInstance.fit();
await new Promise(resolve => setTimeout(resolve, 500));
// 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');
// Add background rect with theme color
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', colors.background);
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
// Add inline styles with theme colors
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
strong {{ font-weight: 700; }}
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
`;
clonedSvg.insertBefore(style, bgRect.nextSibling);
// Convert foreignObject to text for better compatibility
const foreignObjects = clonedSvg.querySelectorAll('foreignObject');
foreignObjects.forEach(fo => {{
const text = fo.textContent || '';
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('x', fo.getAttribute('x') || '0');
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
textEl.setAttribute('fill', colors.text);
textEl.setAttribute('font-family', 'sans-serif');
textEl.setAttribute('font-size', '14');
textEl.textContent = text.trim();
g.appendChild(textEl);
fo.parentNode.replaceChild(g, fo);
}});
// Serialize SVG to string
const svgData = new XMLSerializer().serializeToString(clonedSvg);
// Cleanup container
document.body.removeChild(container);
// Convert SVG string to Blob
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
// Upload file to OpenWebUI API
console.log("[MindMap Image] Uploading SVG file...");
const token = localStorage.getItem("token");
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/files/', {{
method: 'POST',
headers: {{
'Authorization': `Bearer ${{token}}`
}},
body: formData
}});
if (!uploadResponse.ok) {{
throw new Error(`Upload failed: ${{uploadResponse.statusText}}`);
}}
const fileData = await uploadResponse.json();
const fileId = fileData.id;
const imageUrl = `/api/v1/files/${{fileId}}/content`;
console.log("[MindMap Image] File uploaded, ID:", fileId);
// Generate markdown image with file URL
const markdownImage = `![🧠 Mind Map](${{imageUrl}})`;
// Update message via API
if (chatId && messageId) {{
// Helper function with retry logic
const fetchWithRetry = async (url, options, retries = 3) => {{
for (let i = 0; i < retries; i++) {{
try {{
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {{
console.log(`[MindMap Image] Retry ${{i + 1}}/${{retries}} for ${{url}}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}} catch (e) {{
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}}
return null;
}};
// Get current chat data
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
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 mindmap images (both base64 and file URL patterns)
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
let cleanedContent = originalContent.replace(mindmapPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// Append new image
newContent = cleanedContent + "\\n\\n" + markdownImage;
// Critical: Update content in both messages array AND history object
// The history object is the source of truth for the database
if (chatData.chat.history && chatData.chat.history.messages) {{
if (chatData.chat.history.messages[messageId]) {{
chatData.chat.history.messages[messageId].content = newContent;
}}
}}
return {{ ...m, content: newContent }};
}}
return m;
}});
}}
if (!newContent) {{
console.warn("[MindMap Image] Could not find message to update");
return;
}}
// Try to update frontend display via event API (optional, may not exist in all versions)
try {{
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
}} catch (eventErr) {{
// Event API is optional, continue with persistence
console.log("[MindMap Image] Event API not available, continuing...");
}}
// Persist to database by updating the entire chat object
// This follows the OpenWebUI Backend-Controlled API Flow
const updatePayload = {{
chat: {{
...chatData.chat,
messages: updatedMessages
// history is already updated in-place above
}}
}};
const persistResponse = await 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("[MindMap Image] ✅ Message persisted successfully!");
}} else {{
console.error("[MindMap Image] ❌ Failed to persist message after retries");
}}
}} else {{
console.warn("[MindMap Image] ⚠️ Missing chatId or messageId, cannot persist");
}}
}} catch (error) {{
console.error("[MindMap 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: Smart Mind Map (v0.8.0) started")
logger.info("Action: Smart Mind Map (v0.9.1) started")
user_ctx = self._get_user_context(__user__)
user_language = user_ctx["user_language"]
user_name = user_ctx["user_name"]
@@ -1090,6 +1512,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, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
await self._emit_status(
__event_emitter__,
"Smart Mind Map: Rendering image...",
False,
)
if __event_call__:
js_code = self._generate_image_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
markdown_syntax=markdown_syntax,
)
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
await self._emit_status(
__event_emitter__, "Smart Mind Map: Image generated!", True
)
await self._emit_notification(
__event_emitter__,
f"Mind map image has been generated, {user_name}!",
"success",
)
logger.info("Action: Smart Mind Map (v0.9.1) completed in image mode")
return body
# HTML mode (default): embed as HTML block
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
@@ -1101,7 +1562,7 @@ class Action:
f"Mind map has been generated, {user_name}!",
"success",
)
logger.info("Action: Smart Mind Map (v0.8.0) completed successfully")
logger.info("Action: Smart Mind Map (v0.9.1) completed in HTML mode")
except Exception as e:
error_message = f"Smart Mind Map processing failed: {str(e)}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -3,7 +3,8 @@ title: 思维导图
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.8.2
version: 0.9.1
openwebui_id: 8d4b097b-219b-4dd2-b509-05fbe6388335
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
"""
@@ -13,7 +14,7 @@ import os
import re
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Any, Callable, Awaitable, Dict, Optional
from zoneinfo import ZoneInfo
from fastapi import Request
@@ -443,7 +444,7 @@ SCRIPT_TEMPLATE_MINDMAP = """
const markdownContent = sourceEl.textContent.trim();
if (!markdownContent) {
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图缺少有效内容。</div>';
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图:缺少有效内容。</div>';
return;
}
@@ -485,7 +486,7 @@ SCRIPT_TEMPLATE_MINDMAP = """
}).catch((error) => {
console.error('Markmap loading error:', error);
containerEl.innerHTML = '<div class="error-message">⚠️ 资源加载失败请稍后重试。</div>';
containerEl.innerHTML = '<div class="error-message">⚠️ 资源加载失败,请稍后重试。</div>';
});
};
@@ -771,19 +772,23 @@ class Action:
)
MODEL_ID: str = Field(
default="",
description="用于文本分析的内置LLM模型ID。如果为空则使用当前对话的模型。",
description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。",
)
MIN_TEXT_LENGTH: int = Field(
default=100,
description="进行思维导图分析所需的最小文本长度字符数",
description="进行思维导图分析所需的最小文本长度(字符数)",
)
CLEAR_PREVIOUS_HTML: bool = Field(
default=False,
description="是否强制清除旧的插件结果如果为 True则不合并直接覆盖",
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)",
)
MESSAGE_COUNT: int = Field(
default=1,
description="用于生成的最近消息数量。设置为1仅使用最后一条消息更大值可包含更多上下文。",
description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
)
OUTPUT_MODE: str = Field(
default="html",
description="输出模式: 'html' 为交互式HTML(默认),'image' 为嵌入Markdown图片。",
)
def __init__(self):
@@ -813,14 +818,52 @@ class Action:
"user_language": user_data.get("language", "zh-CN"),
}
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_markdown_syntax(self, llm_output: str) -> str:
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
if match:
extracted_content = match.group(1).strip()
else:
logger.warning(
"LLM输出未严格遵循预期Markdown格式将整个输出作为摘要处理。"
)
logger.warning("LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。")
extracted_content = llm_output.strip()
return extracted_content.replace("</script>", "<\\/script>")
@@ -844,7 +887,7 @@ class Action:
return re.sub(pattern, "", content).strip()
def _extract_text_content(self, content) -> str:
"""从消息内容中提取文本支持多模态消息格式"""
"""从消息内容中提取文本,支持多模态消息格式"""
if isinstance(content, str):
return content
elif isinstance(content, list):
@@ -867,7 +910,7 @@ class Action:
user_language: str = "zh-CN",
) -> str:
"""
将新内容合并到现有的 HTML 容器中或者创建一个新的容器。
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
"""
if (
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
@@ -900,14 +943,392 @@ class Action:
return base_html.strip()
def _generate_image_js_code(
self,
unique_id: str,
chat_id: str,
message_id: str,
markdown_syntax: str,
) -> str:
"""生成用于前端 SVG 渲染和图片嵌入的 JavaScript 代码"""
# 转义语法以便嵌入 JS
syntax_escaped = (
markdown_syntax.replace("\\", "\\\\")
.replace("`", "\\`")
.replace("${", "\\${")
.replace("</script>", "<\\/script>")
)
return f"""
(async function() {{
const uniqueId = "{unique_id}";
const chatId = "{chat_id}";
const messageId = "{message_id}";
const defaultWidth = 1200;
const defaultHeight = 800;
// 主题检测 - 检查 OpenWebUI 当前主题
const detectTheme = () => {{
try {{
// 1. 检查 html/body 的 class 或 data-theme 属性
const html = document.documentElement;
const body = document.body;
const htmlClass = html ? html.className : '';
const bodyClass = body ? body.className : '';
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
return 'dark';
}}
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
return 'light';
}}
// 2. 检查 meta theme-color
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (metas.length > 0) {{
const color = metas[metas.length - 1].content.trim();
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
if (m) {{
const hex = m[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luma < 0.5 ? 'dark' : 'light';
}}
}}
// 3. 检查系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
return 'dark';
}}
return 'light';
}} catch (e) {{
return 'light';
}}
}};
const currentTheme = detectTheme();
console.log("[思维导图图片] 检测到主题:", currentTheme);
// 基于主题的颜色配置
const colors = currentTheme === 'dark' ? {{
background: '#1f2937',
text: '#e5e7eb',
link: '#94a3b8',
nodeStroke: '#64748b'
}} : {{
background: '#ffffff',
text: '#1f2937',
link: '#546e7a',
nodeStroke: '#94a3b8'
}};
// 自动检测聊天容器宽度以实现自适应
let svgWidth = defaultWidth;
let svgHeight = defaultHeight;
const chatContainer = document.getElementById('chat-container');
if (chatContainer) {{
const containerWidth = chatContainer.clientWidth;
if (containerWidth > 100) {{
// 使用容器宽度的90%(留出边距)
svgWidth = Math.floor(containerWidth * 0.9);
// 根据默认尺寸保持宽高比
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
console.log("[思维导图图片] 自动检测容器宽度:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
}}
}}
console.log("[思维导图图片] 开始渲染...");
console.log("[思维导图图片] chatId:", chatId, "messageId:", messageId);
try {{
// 加载 D3
if (typeof d3 === 'undefined') {{
console.log("[思维导图图片] 正在加载 D3...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
// 加载 markmap-lib
if (!window.markmap || !window.markmap.Transformer) {{
console.log("[思维导图图片] 正在加载 markmap-lib...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/markmap-lib@0.17';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
// 加载 markmap-view
if (!window.markmap || !window.markmap.Markmap) {{
console.log("[思维导图图片] 正在加载 markmap-view...");
await new Promise((resolve, reject) => {{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/markmap-view@0.17';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
}});
}}
const {{ Transformer, Markmap }} = window.markmap;
// 获取 markdown 语法
let syntaxContent = `{syntax_escaped}`;
console.log("[思维导图图片] 语法长度:", syntaxContent.length);
// 创建离屏容器
const container = document.createElement('div');
container.id = 'mindmap-offscreen-' + uniqueId;
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;';
document.body.appendChild(container);
// 创建 SVG 元素
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgEl.setAttribute('width', svgWidth);
svgEl.setAttribute('height', svgHeight);
svgEl.style.width = svgWidth + 'px';
svgEl.style.height = svgHeight + 'px';
svgEl.style.backgroundColor = colors.background;
container.appendChild(svgEl);
// 将 markdown 转换为树结构
const transformer = new Transformer();
const {{ root }} = transformer.transform(syntaxContent);
// 创建 markmap 实例
const options = {{
autoFit: true,
initialExpandLevel: Infinity,
zoom: false,
pan: false
}};
console.log("[思维导图图片] 正在渲染 markmap...");
const markmapInstance = Markmap.create(svgEl, options, root);
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 1500));
markmapInstance.fit();
await new Promise(resolve => setTimeout(resolve, 500));
// 克隆并准备 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');
// 添加背景矩形(使用主题颜色)
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', colors.background);
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
// 添加内联样式(使用主题颜色)
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
strong {{ font-weight: 700; }}
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
`;
clonedSvg.insertBefore(style, bgRect.nextSibling);
// 将 foreignObject 转换为 text 以提高兼容性
const foreignObjects = clonedSvg.querySelectorAll('foreignObject');
foreignObjects.forEach(fo => {{
const text = fo.textContent || '';
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('x', fo.getAttribute('x') || '0');
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
textEl.setAttribute('fill', colors.text);
textEl.setAttribute('font-family', 'sans-serif');
textEl.setAttribute('font-size', '14');
textEl.textContent = text.trim();
g.appendChild(textEl);
fo.parentNode.replaceChild(g, fo);
}});
// 序列化 SVG 为字符串
const svgData = new XMLSerializer().serializeToString(clonedSvg);
// 清理容器
document.body.removeChild(container);
// 将 SVG 字符串转换为 Blob
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
// 上传文件到 OpenWebUI API
console.log("[思维导图图片] 正在上传 SVG 文件...");
const token = localStorage.getItem("token");
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/files/', {{
method: 'POST',
headers: {{
'Authorization': `Bearer ${{token}}`
}},
body: formData
}});
if (!uploadResponse.ok) {{
throw new Error(`上传失败: ${{uploadResponse.statusText}}`);
}}
const fileData = await uploadResponse.json();
const fileId = fileData.id;
const imageUrl = `/api/v1/files/${{fileId}}/content`;
console.log("[思维导图图片] 文件已上传, ID:", fileId);
// 生成包含文件 URL 的 markdown 图片
const markdownImage = `![🧠 思维导图](${{imageUrl}})`;
// 通过 API 更新消息
if (chatId && messageId) {{
const token = localStorage.getItem("token");
// 带重试逻辑的请求函数
const fetchWithRetry = async (url, options, retries = 3) => {{
for (let i = 0; i < retries; i++) {{
try {{
const response = await fetch(url, options);
if (response.ok) return response;
if (i < retries - 1) {{
console.log(`[思维导图图片] 重试 ${{i + 1}}/${{retries}}: ${{url}}`);
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}} catch (e) {{
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}}
}}
return null;
}};
// 获取当前聊天数据
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
method: "GET",
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 || "";
// 移除已有的思维导图图片 (包括 base64 和文件 URL 格式)
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
let cleanedContent = originalContent.replace(mindmapPattern, "");
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
// 追加新图片
newContent = cleanedContent + "\\n\\n" + markdownImage;
// 关键: 同时更新 messages 数组和 history 对象中的内容
// 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("[思维导图图片] 找不到要更新的消息");
return;
}}
// 尝试通过事件 API 更新前端显示(可选,部分版本可能不支持)
try {{
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
method: "POST",
headers: {{
"Content-Type": "application/json",
"Authorization": `Bearer ${{token}}`
}},
body: JSON.stringify({{
type: "chat:message",
data: {{ content: newContent }}
}})
}});
}} catch (eventErr) {{
// 事件 API 是可选的,继续执行持久化
console.log("[思维导图图片] 事件 API 不可用,继续执行...");
}}
// 通过更新整个聊天对象来持久化到数据库
// 遵循 OpenWebUI 后端控制的 API 流程
const updatePayload = {{
chat: {{
...chatData.chat,
messages: updatedMessages
// history 已在上面原地更新
}}
}};
const persistResponse = await 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("[思维导图图片] ✅ 消息已持久化保存!");
}} else {{
console.error("[思维导图图片] ❌ 重试后仍然无法持久化消息");
}}
}} else {{
console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId,无法持久化");
}}
}} catch (error) {{
console.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: 思维导图 (v12 - Final Feedback Fix) started")
logger.info("Action: 思维导图 (v0.9.1) started")
user_ctx = self._get_user_context(__user__)
user_language = user_ctx["user_language"]
user_name = user_ctx["user_name"]
@@ -923,7 +1344,7 @@ class Action:
current_year = now_dt.strftime("%Y")
current_timezone_str = tz_env or "UTC"
except Exception as e:
logger.warning(f"获取时区信息失败: {e}使用默认值。")
logger.warning(f"获取时区信息失败: {e},使用默认值。")
now = datetime.now()
current_date_time_str = now.strftime("%Y年%m月%d%H:%M:%S")
current_weekday_zh = "未知星期"
@@ -931,7 +1352,7 @@ class Action:
current_timezone_str = "未知时区"
await self._emit_notification(
__event_emitter__, "思维导图已启动正在为您生成思维导图...", "info"
__event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info"
)
messages = body.get("messages")
@@ -980,7 +1401,7 @@ class Action:
long_text_content = original_content.strip()
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
short_text_message = f"文本内容过短({len(long_text_content)}字符)无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。"
short_text_message = f"文本内容过短({len(long_text_content)}字符),无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。"
await self._emit_notification(
__event_emitter__, short_text_message, "warning"
)
@@ -1021,7 +1442,7 @@ class Action:
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"无法获取用户对象用户ID: {user_id}")
raise ValueError(f"无法获取用户对象,用户ID: {user_id}")
llm_response = await generate_chat_completion(
__request__, llm_payload, user_obj
@@ -1084,26 +1505,65 @@ class Action:
user_language,
)
# 检查输出模式
if self.valves.OUTPUT_MODE == "image":
# 图片模式: 使用 JavaScript 渲染并嵌入为 Markdown 图片
chat_id = self._extract_chat_id(body, __metadata__)
message_id = self._extract_message_id(body, __metadata__)
await self._emit_status(
__event_emitter__,
"思维导图: 正在渲染图片...",
False,
)
if __event_call__:
js_code = self._generate_image_js_code(
unique_id=unique_id,
chat_id=chat_id,
message_id=message_id,
markdown_syntax=markdown_syntax,
)
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("Action: 思维导图 (v0.9.1) 图片模式完成")
return body
# HTML 模式(默认): 嵌入为 HTML 块
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
await self._emit_status(__event_emitter__, "思维导图: 绘制完成", True)
await self._emit_status(__event_emitter__, "思维导图: 绘制完成!", True)
await self._emit_notification(
__event_emitter__, f"思维导图已生成{user_name}", "success"
__event_emitter__, f"思维导图已生成,{user_name}!", "success"
)
logger.info("Action: 思维导图 (v12) completed successfully")
logger.info("Action: 思维导图 (v0.9.1) HTML 模式完成")
except Exception as e:
error_message = f"思维导图处理失败: {str(e)}"
logger.error(f"思维导图错误: {error_message}", exc_info=True)
user_facing_error = f"抱歉思维导图在处理时遇到错误: {str(e)}\n请检查Open WebUI后端日志获取更多详情。"
user_facing_error = f"抱歉,思维导图在处理时遇到错误: {str(e)}\n请检查Open WebUI后端日志获取更多详情。"
body["messages"][-1][
"content"
] = f"{long_text_content}\n\n❌ **错误:** {user_facing_error}"
await self._emit_status(__event_emitter__, "思维导图: 处理失败。", True)
await self._emit_notification(
__event_emitter__, f"思维导图生成失败, {user_name}", "error"
__event_emitter__, f"思维导图生成失败, {user_name}!", "error"
)
return body

View File

@@ -1,30 +0,0 @@
# Deep Reading & Summary
A powerful tool for analyzing long texts, generating detailed summaries, key points, and actionable insights.
## Features
- **Deep Analysis**: Goes beyond simple summarization to understand the core message.
- **Key Point Extraction**: Identifies and lists the most important information.
- **Actionable Advice**: Provides practical suggestions based on the text content.
## Usage
1. Install the plugin.
2. Send a long text or article to the chat.
3. Click the "Deep Reading" button (or trigger via command).
## Author
Fu-Jie
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## License
MIT License
## Changelog
### v0.1.2
- Removed debug messages from output

View File

@@ -1,30 +0,0 @@
# 深度阅读与摘要 (Deep Reading & Summary)
一个强大的长文本分析工具,用于生成详细摘要、关键信息点和可执行的行动建议。
## 功能特点
- **深度分析**:超越简单的总结,深入理解核心信息。
- **关键点提取**:识别并列出最重要的信息点。
- **行动建议**:基于文本内容提供切实可行的建议。
## 使用方法
1. 安装插件。
2. 发送长文本或文章到聊天框。
3. 点击“精读”按钮(或通过命令触发)。
## 作者
Fu-Jie
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
## 许可证
MIT License
## 更新日志
### v0.1.2
- 移除输出中的调试信息

View File

@@ -1,674 +0,0 @@
"""
title: Deep Reading & Summary
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.1.2
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAxMmgtNSIvPjxwYXRoIGQ9Ik0xNSA4aC01Ii8+PHBhdGggZD0iTTE5IDE3VjVhMiAyIDAgMCAwLTItMkg0Ii8+PHBhdGggZD0iTTggMjFoMTJhMiAyIDAgMCAwIDItMnYtMWExIDEgMCAwIDAtMS0xSDExYTEgMSAwIDAgMC0xIDF2MWEyIDIgMCAxIDEtNCAwVjVhMiAyIDAgMSAwLTQgMHYyYTEgMSAwIDAgMCAxIDFoMyIvPjwvc3ZnPg==
description: Provides deep reading analysis and summarization for long texts.
requirements: jinja2, markdown
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
import logging
import re
from fastapi import Request
from datetime import datetime
import pytz
import markdown
from jinja2 import Template
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# =================================================================
# HTML Wrapper Template (supports multiple plugins and grid layout)
# =================================================================
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>
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-wrap: wrap;
gap: 20px;
align-items: flex-start;
width: 100%;
}
.plugin-item {
flex: 1 1 400px; /* Default width, allows shrinking/growing */
min-width: 300px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
}
.plugin-item:hover {
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
}
@media (max-width: 768px) {
.plugin-item { flex: 1 1 100%; }
}
/* STYLES_INSERTION_POINT */
</style>
</head>
<body>
<div id="main-container">
<!-- CONTENT_INSERTION_POINT -->
</div>
<!-- SCRIPTS_INSERTION_POINT -->
</body>
</html>
"""
# =================================================================
# Internal LLM Prompts
# =================================================================
SYSTEM_PROMPT_READING_ASSISTANT = """
You are a professional Deep Text Analysis Expert, specializing in reading long texts and extracting the essence. Your task is to conduct a comprehensive and in-depth analysis.
Please provide the following:
1. **Detailed Summary**: Summarize the core content of the text in 2-3 paragraphs, ensuring accuracy and completeness. Do not be too brief; ensure the reader fully understands the main idea.
2. **Key Information Points**: List 5-8 most important facts, viewpoints, or arguments. Each point should:
- Be specific and insightful
- Include necessary details and context
- Use Markdown list format
3. **Actionable Advice**: Identify and refine specific, actionable items from the text. Each suggestion should:
- Be clear and actionable
- Include execution priority or timing suggestions
- If there are no clear action items, provide learning suggestions or thinking directions
Please strictly follow these guidelines:
- **Language**: All output must be in the user's specified language.
- **Format**: Please strictly follow the Markdown format below, ensuring each section has a clear header:
## Summary
[Detailed summary content here, 2-3 paragraphs, use Markdown **bold** or *italic* to emphasize key points]
## Key Information Points
- [Key Point 1: Include specific details and context]
- [Key Point 2: Include specific details and context]
- [Key Point 3: Include specific details and context]
- [At least 5, at most 8 key points]
## Actionable Advice
- [Action Item 1: Specific, actionable, include priority]
- [Action Item 2: Specific, actionable, include priority]
- [If no clear action items, provide learning suggestions or thinking directions]
- **Depth First**: Analysis should be deep and comprehensive, not superficial.
- **Action Oriented**: Focus on actionable suggestions and next steps.
- **Analysis Results Only**: Do not include any extra pleasantries, explanations, or leading text.
"""
USER_PROMPT_GENERATE_SUMMARY = """
Please conduct a deep analysis of the following long text, providing:
1. Detailed Summary (2-3 paragraphs, comprehensive overview)
2. Key Information Points List (5-8 items, including specific details)
3. Actionable Advice (Specific, clear, including priority)
---
**User Context:**
User Name: {user_name}
Current Date/Time: {current_date_time_str}
Weekday: {current_weekday}
Timezone: {current_timezone_str}
User Language: {user_language}
---
**Long Text Content:**
```
{long_text_content}
```
Please conduct a deep and comprehensive analysis, focusing on actionable advice.
"""
# =================================================================
# Frontend HTML Template (Jinja2 Syntax)
# =================================================================
CSS_TEMPLATE_SUMMARY = """
:root {
--primary-color: #4285f4;
--secondary-color: #1e88e5;
--action-color: #34a853;
--background-color: #f8f9fa;
--card-bg-color: #ffffff;
--text-color: #202124;
--muted-text-color: #5f6368;
--border-color: #dadce0;
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
--shadow: 0 1px 3px rgba(60,64,67,.3);
--border-radius: 8px;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.summary-container-wrapper {
font-family: var(--font-family);
line-height: 1.8;
color: var(--text-color);
height: 100%;
display: flex;
flex-direction: column;
}
.summary-container-wrapper .header {
background: var(--header-gradient);
color: white;
padding: 20px 24px;
text-align: center;
}
.summary-container-wrapper .header h1 {
margin: 0;
font-size: 1.5em;
font-weight: 500;
letter-spacing: -0.5px;
}
.summary-container-wrapper .user-context {
font-size: 0.8em;
color: var(--muted-text-color);
background-color: #f1f3f4;
padding: 8px 16px;
display: flex;
justify-content: space-around;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
}
.summary-container-wrapper .user-context span { margin: 2px 8px; }
.summary-container-wrapper .content { padding: 20px; flex-grow: 1; }
.summary-container-wrapper .section {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e8eaed;
}
.summary-container-wrapper .section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.summary-container-wrapper .section h2 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.2em;
font-weight: 500;
color: var(--text-color);
display: flex;
align-items: center;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary-color);
}
.summary-container-wrapper .section h2 .icon {
margin-right: 8px;
font-size: 1.1em;
line-height: 1;
}
.summary-container-wrapper .summary-section h2 { border-bottom-color: var(--primary-color); }
.summary-container-wrapper .keypoints-section h2 { border-bottom-color: var(--secondary-color); }
.summary-container-wrapper .actions-section h2 { border-bottom-color: var(--action-color); }
.summary-container-wrapper .html-content {
font-size: 0.95em;
line-height: 1.7;
}
.summary-container-wrapper .html-content p:first-child { margin-top: 0; }
.summary-container-wrapper .html-content p:last-child { margin-bottom: 0; }
.summary-container-wrapper .html-content ul {
list-style: none;
padding-left: 0;
margin: 12px 0;
}
.summary-container-wrapper .html-content li {
padding: 8px 0 8px 24px;
position: relative;
margin-bottom: 6px;
line-height: 1.6;
}
.summary-container-wrapper .html-content li::before {
position: absolute;
left: 0;
top: 8px;
font-family: 'Arial';
font-weight: bold;
font-size: 1em;
}
.summary-container-wrapper .keypoints-section .html-content li::before {
content: '';
color: var(--secondary-color);
font-size: 1.3em;
top: 5px;
}
.summary-container-wrapper .actions-section .html-content li::before {
content: '';
color: var(--action-color);
}
.summary-container-wrapper .no-content {
color: var(--muted-text-color);
font-style: italic;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
}
.summary-container-wrapper .footer {
text-align: center;
padding: 16px;
font-size: 0.8em;
color: #5f6368;
background-color: #f8f9fa;
border-top: 1px solid var(--border-color);
}
"""
CONTENT_TEMPLATE_SUMMARY = """
<div class="summary-container-wrapper">
<div class="header">
<h1>📖 Deep Reading: Analysis Report</h1>
</div>
<div class="user-context">
<span><strong>User:</strong> {user_name}</span>
<span><strong>Time:</strong> {current_date_time_str}</span>
</div>
<div class="content">
<div class="section summary-section">
<h2><span class="icon">📝</span>Detailed Summary</h2>
<div class="html-content">{summary_html}</div>
</div>
<div class="section keypoints-section">
<h2><span class="icon">💡</span>Key Information Points</h2>
<div class="html-content">{keypoints_html}</div>
</div>
<div class="section actions-section">
<h2><span class="icon">🎯</span>Actionable Advice</h2>
<div class="html-content">{actions_html}</div>
</div>
</div>
<div class="footer">
<p>&copy; {current_year} Deep Reading - Text Analysis Service</p>
</div>
</div>
"""
class Action:
class Valves(BaseModel):
SHOW_STATUS: bool = Field(
default=True,
description="Whether to show operation status updates in the chat interface.",
)
MODEL_ID: str = Field(
default="",
description="Built-in LLM Model ID used for text analysis. If empty, uses the current conversation's model.",
)
MIN_TEXT_LENGTH: int = Field(
default=200,
description="Minimum text length required for deep analysis (characters). Recommended 200+.",
)
RECOMMENDED_MIN_LENGTH: int = Field(
default=500,
description="Recommended minimum text length for best analysis results.",
)
CLEAR_PREVIOUS_HTML: bool = Field(
default=False,
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
)
MESSAGE_COUNT: int = Field(
default=1,
description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
)
def __init__(self):
self.valves = self.Valves()
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
"""
Parse LLM Markdown output and convert to HTML fragments.
"""
summary_match = re.search(
r"##\s*Summary\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL | re.IGNORECASE
)
keypoints_match = re.search(
r"##\s*Key Information Points\s*\n(.*?)(?=\n##|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
actions_match = re.search(
r"##\s*Actionable Advice\s*\n(.*?)(?=\n##|$)",
llm_output,
re.DOTALL | re.IGNORECASE,
)
summary_md = summary_match.group(1).strip() if summary_match else ""
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
actions_md = actions_match.group(1).strip() if actions_match else ""
if not any([summary_md, keypoints_md, actions_md]):
summary_md = llm_output.strip()
logger.warning(
"LLM output did not follow expected Markdown format. Treating entire output as summary."
)
# Use 'nl2br' extension to convert newlines \n to <br>
md_extensions = ["nl2br"]
summary_html = (
markdown.markdown(summary_md, extensions=md_extensions)
if summary_md
else '<p class="no-content">Failed to extract summary.</p>'
)
keypoints_html = (
markdown.markdown(keypoints_md, extensions=md_extensions)
if keypoints_md
else '<p class="no-content">Failed to extract key information points.</p>'
)
actions_html = (
markdown.markdown(actions_md, extensions=md_extensions)
if actions_md
else '<p class="no-content">No explicit actionable advice.</p>'
)
return {
"summary_html": summary_html,
"keypoints_html": keypoints_html,
"actions_html": actions_html,
}
async def _emit_status(self, emitter, 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, content: str, ntype: str = "info"):
"""Emits a notification event (info/success/warning/error)."""
if emitter:
await emitter(
{"type": "notification", "data": {"type": ntype, "content": content}}
)
def _remove_existing_html(self, content: str) -> str:
"""Removes existing plugin-generated HTML code blocks from the content."""
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, supporting multimodal message formats"""
if isinstance(content, str):
return content
elif isinstance(content, list):
# Multimodal message: [{"type": "text", "text": "..."}, {"type": "image_url", ...}]
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_code: str,
new_content: str,
new_styles: str = "",
new_scripts: str = "",
user_language: str = "en-US",
) -> str:
"""
Merges new content into an existing HTML container, or creates a new one.
"""
if (
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
and "<!-- CONTENT_INSERTION_POINT -->" in existing_html_code
):
base_html = existing_html_code
base_html = re.sub(r"^```html\s*", "", base_html)
base_html = re.sub(r"\s*```$", "", base_html)
else:
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
wrapped_content = 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_content}\n<!-- CONTENT_INSERTION_POINT -->",
)
if new_scripts:
base_html = base_html.replace(
"<!-- SCRIPTS_INSERTION_POINT -->",
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
)
return base_html.strip()
def _build_content_html(self, context: dict) -> str:
"""
Build content HTML using context data.
"""
return (
CONTENT_TEMPLATE_SUMMARY.replace(
"{user_name}", context.get("user_name", "User")
)
.replace(
"{current_date_time_str}", context.get("current_date_time_str", "")
)
.replace("{current_year}", context.get("current_year", ""))
.replace("{summary_html}", context.get("summary_html", ""))
.replace("{keypoints_html}", context.get("keypoints_html", ""))
.replace("{actions_html}", context.get("actions_html", ""))
)
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Any] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: Deep Reading Started (v2.0.0)")
if isinstance(__user__, (list, tuple)):
user_language = (
__user__[0].get("language", "en-US") if __user__ else "en-US"
)
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
user_id = (
__user__[0]["id"]
if __user__ and "id" in __user__[0]
else "unknown_user"
)
elif isinstance(__user__, dict):
user_language = __user__.get("language", "en-US")
user_name = __user__.get("name", "User")
user_id = __user__.get("id", "unknown_user")
now = datetime.now()
current_date_time_str = now.strftime("%B %d, %Y %H:%M:%S")
current_weekday = now.strftime("%A")
current_year = now.strftime("%Y")
current_timezone_str = "Unknown Timezone"
original_content = ""
try:
messages = body.get("messages", [])
if not messages:
raise ValueError("Unable to get valid user message content.")
# Get last N messages based on MESSAGE_COUNT
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
recent_messages = messages[-message_count:]
# Aggregate content from selected messages with labels
aggregated_parts = []
for i, msg in enumerate(recent_messages, 1):
text_content = self._extract_text_content(msg.get("content"))
if text_content:
role = msg.get("role", "unknown")
role_label = (
"User"
if role == "user"
else "Assistant" if role == "assistant" else role
)
aggregated_parts.append(f"{text_content}")
if not aggregated_parts:
raise ValueError("Unable to get valid user message content.")
original_content = "\n\n---\n\n".join(aggregated_parts)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
short_text_message = f"Text content too short ({len(original_content)} chars), recommended at least {self.valves.MIN_TEXT_LENGTH} chars for effective deep analysis.\n\n💡 Tip: For short texts, consider using '⚡ Flash Card' for quick refinement."
await self._emit_notification(
__event_emitter__, short_text_message, "warning"
)
return {
"messages": [
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
]
}
# Recommend for longer texts
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
await self._emit_notification(
__event_emitter__,
f"Text length is {len(original_content)} chars. Recommended {self.valves.RECOMMENDED_MIN_LENGTH}+ chars for best analysis results.",
"info",
)
await self._emit_notification(
__event_emitter__,
"📖 Deep Reading started, analyzing deeply...",
"info",
)
await self._emit_status(
__event_emitter__,
"📖 Deep Reading: Analyzing text, extracting essence...",
False,
)
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
current_weekday=current_weekday,
current_timezone_str=current_timezone_str,
user_language=user_language,
long_text_content=original_content,
)
# Determine model to use
target_model = self.valves.MODEL_ID
if not target_model:
target_model = body.get("model")
llm_payload = {
"model": target_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
{"role": "user", "content": formatted_user_prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"Unable to get user object, User ID: {user_id}")
llm_response = await generate_chat_completion(
__request__, llm_payload, user_obj
)
assistant_response_content = llm_response["choices"][0]["message"][
"content"
]
processed_content = self._process_llm_output(assistant_response_content)
context = {
"user_language": user_language,
"user_name": user_name,
"current_date_time_str": current_date_time_str,
"current_weekday": current_weekday,
"current_year": current_year,
**processed_content,
}
content_html = self._build_content_html(context)
# Extract existing HTML if any
existing_html_block = ""
match = re.search(
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
original_content,
)
if match:
existing_html_block = match.group(1)
if self.valves.CLEAR_PREVIOUS_HTML:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
)
else:
if existing_html_block:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
existing_html_block,
content_html,
CSS_TEMPLATE_SUMMARY,
"",
user_language,
)
else:
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
)
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
await self._emit_status(
__event_emitter__, "📖 Deep Reading: Analysis complete!", True
)
await self._emit_notification(
__event_emitter__,
f"📖 Deep Reading complete, {user_name}! Deep analysis report generated.",
"success",
)
except Exception as e:
error_message = f"Deep Reading processing failed: {str(e)}"
logger.error(f"Deep Reading Error: {error_message}", exc_info=True)
user_facing_error = f"Sorry, Deep Reading encountered an error while processing: {str(e)}.\nPlease check Open WebUI backend logs for more details."
body["messages"][-1][
"content"
] = f"{original_content}\n\n❌ **Error:** {user_facing_error}"
await self._emit_status(
__event_emitter__, "Deep Reading: Processing failed.", True
)
await self._emit_notification(
__event_emitter__,
f"Deep Reading processing failed, {user_name}!",
"error",
)
return body

View File

@@ -1,663 +0,0 @@
"""
title: 精读 (Deep Reading)
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNDI4NWY0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWU4OGU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTE0IDJsNiA2aC02eiIgZmlsbD0iIzFlODhlNSIgb3BhY2l0eT0iMC42Ii8+PGxpbmUgeDE9IjgiIHkxPSIxMyIgeDI9IjE2IiB5Mj0iMTMiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48bGluZSB4MT0iOCIgeTE9IjE3IiB4Mj0iMTQiIHkyPSIxNyIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiNmZmQ3MDAiLz48cGF0aCBkPSJNMTYgMTZsMS41IDEuNSIgc3Ryb2tlPSIjNDI4NWY0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==
version: 0.1.2
description: 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议,适合工作和学习场景。
requirements: jinja2, markdown
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
import logging
import re
from fastapi import Request
from datetime import datetime
import pytz
import markdown
from jinja2 import Template
from open_webui.utils.chat import generate_chat_completion
from open_webui.models.users import Users
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# =================================================================
# 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>
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-wrap: wrap;
gap: 20px;
align-items: flex-start;
width: 100%;
}
.plugin-item {
flex: 1 1 400px; /* 默认宽度,允许伸缩 */
min-width: 300px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
}
.plugin-item:hover {
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
}
@media (max-width: 768px) {
.plugin-item { flex: 1 1 100%; }
}
/* STYLES_INSERTION_POINT */
</style>
</head>
<body>
<div id="main-container">
<!-- CONTENT_INSERTION_POINT -->
</div>
<!-- SCRIPTS_INSERTION_POINT -->
</body>
</html>
"""
# =================================================================
# 内部 LLM 提示词设计
# =================================================================
SYSTEM_PROMPT_READING_ASSISTANT = """
你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。
请提供以下内容:
1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。
2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该:
- 具体且有深度
- 包含必要的细节和背景
- 使用 Markdown 列表格式
3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该:
- 明确且可操作
- 包含执行的优先级或时间建议
- 如果没有明确的行动项,可以提供学习建议或思考方向
请严格遵循以下指导原则:
- **语言**:所有输出必须使用用户指定的语言。
- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题:
## 摘要
[这里是详细的摘要内容2-3段话可以使用 Markdown 进行**加粗**或*斜体*强调重点]
## 关键信息点
- [关键点1包含具体细节和背景]
- [关键点2包含具体细节和背景]
- [关键点3包含具体细节和背景]
- [至少5个最多8个关键点]
## 行动建议
- [行动项1具体、可执行包含优先级]
- [行动项2具体、可执行包含优先级]
- [如果没有明确行动项,提供学习建议或思考方向]
- **深度优先**:分析要深入、全面,不要浮于表面。
- **行动导向**:重点关注可执行的建议和下一步行动。
- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。
"""
USER_PROMPT_GENERATE_SUMMARY = """
请对以下长篇文本进行深度分析,提供:
1. 详细的摘要2-3段话全面概括文本内容
2. 关键信息点列表5-8个包含具体细节
3. 可执行的行动建议(具体、明确,包含优先级)
---
**用户上下文信息:**
用户姓名: {user_name}
当前日期时间: {current_date_time_str}
当前星期: {current_weekday}
当前时区: {current_timezone_str}
用户语言: {user_language}
---
**长篇文本内容:**
```
{long_text_content}
```
请进行深入、全面的分析,重点关注可执行的行动建议。
"""
# =================================================================
# 前端 HTML 模板 (Jinja2 语法)
# =================================================================
CSS_TEMPLATE_SUMMARY = """
:root {
--primary-color: #4285f4;
--secondary-color: #1e88e5;
--action-color: #34a853;
--background-color: #f8f9fa;
--card-bg-color: #ffffff;
--text-color: #202124;
--muted-text-color: #5f6368;
--border-color: #dadce0;
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
--shadow: 0 1px 3px rgba(60,64,67,.3);
--border-radius: 8px;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.summary-container-wrapper {
font-family: var(--font-family);
line-height: 1.8;
color: var(--text-color);
height: 100%;
display: flex;
flex-direction: column;
}
.summary-container-wrapper .header {
background: var(--header-gradient);
color: white;
padding: 20px 24px;
text-align: center;
}
.summary-container-wrapper .header h1 {
margin: 0;
font-size: 1.5em;
font-weight: 500;
letter-spacing: -0.5px;
}
.summary-container-wrapper .user-context {
font-size: 0.8em;
color: var(--muted-text-color);
background-color: #f1f3f4;
padding: 8px 16px;
display: flex;
justify-content: space-around;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
}
.summary-container-wrapper .user-context span { margin: 2px 8px; }
.summary-container-wrapper .content { padding: 20px; flex-grow: 1; }
.summary-container-wrapper .section {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e8eaed;
}
.summary-container-wrapper .section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.summary-container-wrapper .section h2 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.2em;
font-weight: 500;
color: var(--text-color);
display: flex;
align-items: center;
padding-bottom: 8px;
border-bottom: 2px solid var(--primary-color);
}
.summary-container-wrapper .section h2 .icon {
margin-right: 8px;
font-size: 1.1em;
line-height: 1;
}
.summary-container-wrapper .summary-section h2 { border-bottom-color: var(--primary-color); }
.summary-container-wrapper .keypoints-section h2 { border-bottom-color: var(--secondary-color); }
.summary-container-wrapper .actions-section h2 { border-bottom-color: var(--action-color); }
.summary-container-wrapper .html-content {
font-size: 0.95em;
line-height: 1.7;
}
.summary-container-wrapper .html-content p:first-child { margin-top: 0; }
.summary-container-wrapper .html-content p:last-child { margin-bottom: 0; }
.summary-container-wrapper .html-content ul {
list-style: none;
padding-left: 0;
margin: 12px 0;
}
.summary-container-wrapper .html-content li {
padding: 8px 0 8px 24px;
position: relative;
margin-bottom: 6px;
line-height: 1.6;
}
.summary-container-wrapper .html-content li::before {
position: absolute;
left: 0;
top: 8px;
font-family: 'Arial';
font-weight: bold;
font-size: 1em;
}
.summary-container-wrapper .keypoints-section .html-content li::before {
content: '';
color: var(--secondary-color);
font-size: 1.3em;
top: 5px;
}
.summary-container-wrapper .actions-section .html-content li::before {
content: '';
color: var(--action-color);
}
.summary-container-wrapper .no-content {
color: var(--muted-text-color);
font-style: italic;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
}
.summary-container-wrapper .footer {
text-align: center;
padding: 16px;
font-size: 0.8em;
color: #5f6368;
background-color: #f8f9fa;
border-top: 1px solid var(--border-color);
}
"""
CONTENT_TEMPLATE_SUMMARY = """
<div class="summary-container-wrapper">
<div class="header">
<h1>📖 精读:深度分析报告</h1>
</div>
<div class="user-context">
<span><strong>用户:</strong> {user_name}</span>
<span><strong>时间:</strong> {current_date_time_str}</span>
</div>
<div class="content">
<div class="section summary-section">
<h2><span class="icon">📝</span>详细摘要</h2>
<div class="html-content">{summary_html}</div>
</div>
<div class="section keypoints-section">
<h2><span class="icon">💡</span>关键信息点</h2>
<div class="html-content">{keypoints_html}</div>
</div>
<div class="section actions-section">
<h2><span class="icon">🎯</span>行动建议</h2>
<div class="html-content">{actions_html}</div>
</div>
</div>
<div class="footer">
<p>&copy; {current_year} 精读 - 深度文本分析服务</p>
</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="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。",
)
RECOMMENDED_MIN_LENGTH: int = Field(
default=500, description="建议的最小文本长度,以获得最佳分析效果。"
)
CLEAR_PREVIOUS_HTML: bool = Field(
default=False,
description="是否强制清除旧的插件结果(如果为 True则不合并直接覆盖",
)
MESSAGE_COUNT: int = Field(
default=1,
description="用于生成的最近消息数量。设置为1仅使用最后一条消息更大值可包含更多上下文。",
)
def __init__(self):
self.valves = self.Valves()
self.weekday_map = {
"Monday": "星期一",
"Tuesday": "星期二",
"Wednesday": "星期三",
"Thursday": "星期四",
"Friday": "星期五",
"Saturday": "星期六",
"Sunday": "星期日",
}
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
"""
解析LLM的Markdown输出,将其转换为HTML片段。
"""
summary_match = re.search(
r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
)
keypoints_match = re.search(
r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
)
actions_match = re.search(
r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
)
summary_md = summary_match.group(1).strip() if summary_match else ""
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
actions_md = actions_match.group(1).strip() if actions_match else ""
if not any([summary_md, keypoints_md, actions_md]):
summary_md = llm_output.strip()
logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。")
# 使用 'nl2br' 扩展将换行符 \n 转换为 <br>
md_extensions = ["nl2br"]
summary_html = (
markdown.markdown(summary_md, extensions=md_extensions)
if summary_md
else '<p class="no-content">未能提取摘要信息。</p>'
)
keypoints_html = (
markdown.markdown(keypoints_md, extensions=md_extensions)
if keypoints_md
else '<p class="no-content">未能提取关键信息点。</p>'
)
actions_html = (
markdown.markdown(actions_md, extensions=md_extensions)
if actions_md
else '<p class="no-content">暂无明确的行动建议。</p>'
)
return {
"summary_html": summary_html,
"keypoints_html": keypoints_html,
"actions_html": actions_html,
}
async def _emit_status(self, emitter, 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, content: str, ntype: str = "info"):
"""发送通知事件 (info/success/warning/error)。"""
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):
# 多模态消息: [{"type": "text", "text": "..."}, {"type": "image_url", ...}]
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_code: str,
new_content: str,
new_styles: str = "",
new_scripts: str = "",
user_language: str = "zh-CN",
) -> str:
"""
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
"""
if (
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
and "<!-- CONTENT_INSERTION_POINT -->" in existing_html_code
):
base_html = existing_html_code
base_html = re.sub(r"^```html\s*", "", base_html)
base_html = re.sub(r"\s*```$", "", base_html)
else:
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
wrapped_content = 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_content}\n<!-- CONTENT_INSERTION_POINT -->",
)
if new_scripts:
base_html = base_html.replace(
"<!-- SCRIPTS_INSERTION_POINT -->",
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
)
return base_html.strip()
def _build_content_html(self, context: dict) -> str:
"""
使用上下文数据构建内容 HTML。
"""
return (
CONTENT_TEMPLATE_SUMMARY.replace(
"{user_name}", context.get("user_name", "用户")
)
.replace(
"{current_date_time_str}", context.get("current_date_time_str", "")
)
.replace("{current_year}", context.get("current_year", ""))
.replace("{summary_html}", context.get("summary_html", ""))
.replace("{keypoints_html}", context.get("keypoints_html", ""))
.replace("{actions_html}", context.get("actions_html", ""))
)
async def action(
self,
body: dict,
__user__: Optional[Dict[str, Any]] = None,
__event_emitter__: Optional[Any] = None,
__request__: Optional[Request] = None,
) -> Optional[dict]:
logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)")
if isinstance(__user__, (list, tuple)):
user_language = (
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
)
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
user_id = (
__user__[0]["id"]
if __user__ and "id" in __user__[0]
else "unknown_user"
)
elif isinstance(__user__, dict):
user_language = __user__.get("language", "zh-CN")
user_name = __user__.get("name", "用户")
user_id = __user__.get("id", "unknown_user")
now = datetime.now()
current_date_time_str = now.strftime("%Y年%m月%d%H:%M:%S")
current_weekday_en = now.strftime("%A")
current_weekday = self.weekday_map.get(current_weekday_en, current_weekday_en)
current_year = now.strftime("%Y")
current_timezone_str = "未知时区"
original_content = ""
try:
messages = body.get("messages", [])
if not messages:
raise ValueError("无法获取有效的用户消息内容。")
# Get last N messages based on MESSAGE_COUNT
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
recent_messages = messages[-message_count:]
# Aggregate content from selected messages with labels
aggregated_parts = []
for i, msg in enumerate(recent_messages, 1):
text_content = self._extract_text_content(msg.get("content"))
if text_content:
role = msg.get("role", "unknown")
role_label = (
"用户"
if role == "user"
else "助手" if role == "assistant" else role
)
aggregated_parts.append(f"{text_content}")
if not aggregated_parts:
raise ValueError("无法获取有效的用户消息内容。")
original_content = "\n\n---\n\n".join(aggregated_parts)
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
await self._emit_notification(
__event_emitter__, short_text_message, "warning"
)
return {
"messages": [
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
]
}
# Recommend for longer texts
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
await self._emit_notification(
__event_emitter__,
f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。",
"info",
)
await self._emit_notification(
__event_emitter__, "📖 精读已启动,正在进行深度分析...", "info"
)
await self._emit_status(
__event_emitter__, "📖 精读: 深入分析文本,提炼精华...", False
)
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
user_name=user_name,
current_date_time_str=current_date_time_str,
current_weekday=current_weekday,
current_timezone_str=current_timezone_str,
user_language=user_language,
long_text_content=original_content,
)
# 确定使用的模型
target_model = self.valves.MODEL_ID
if not target_model:
target_model = body.get("model")
llm_payload = {
"model": target_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
{"role": "user", "content": formatted_user_prompt},
],
"stream": False,
}
user_obj = Users.get_user_by_id(user_id)
if not user_obj:
raise ValueError(f"无法获取用户对象, 用户ID: {user_id}")
llm_response = await generate_chat_completion(
__request__, llm_payload, user_obj
)
assistant_response_content = llm_response["choices"][0]["message"][
"content"
]
processed_content = self._process_llm_output(assistant_response_content)
context = {
"user_language": user_language,
"user_name": user_name,
"current_date_time_str": current_date_time_str,
"current_weekday": current_weekday,
"current_year": current_year,
**processed_content,
}
content_html = self._build_content_html(context)
# Extract existing HTML if any
existing_html_block = ""
match = re.search(
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
original_content,
)
if match:
existing_html_block = match.group(1)
if self.valves.CLEAR_PREVIOUS_HTML:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
)
else:
if existing_html_block:
original_content = self._remove_existing_html(original_content)
final_html = self._merge_html(
existing_html_block,
content_html,
CSS_TEMPLATE_SUMMARY,
"",
user_language,
)
else:
final_html = self._merge_html(
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
)
html_embed_tag = f"```html\n{final_html}\n```"
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
await self._emit_status(__event_emitter__, "📖 精读: 分析完成!", True)
await self._emit_notification(
__event_emitter__,
f"📖 精读完成,{user_name}!深度分析报告已生成。",
"success",
)
except Exception as e:
error_message = f"精读处理失败: {str(e)}"
logger.error(f"精读错误: {error_message}", exc_info=True)
user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}\n请检查Open WebUI后端日志获取更多详情。"
body["messages"][-1][
"content"
] = f"{original_content}\n\n❌ **错误:** {user_facing_error}"
await self._emit_status(__event_emitter__, "精读: 处理失败。", True)
await self._emit_notification(
__event_emitter__, f"精读处理失败, {user_name}!", "error"
)
return body

View File

@@ -5,7 +5,8 @@ author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
description: Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.
version: 1.1.0
version: 1.1.1
openwebui_id: b1655bc8-6de9-4cad-8cb5-a6f7829a02ce
license: MIT
═══════════════════════════════════════════════════════════════════════════════
@@ -138,6 +139,10 @@ debug_mode
Default: true
Description: Prints detailed debug information to the log. Recommended to set to `false` in production.
show_debug_log
Default: false
Description: Print debug logs to browser console (F12). Useful for frontend debugging.
🔧 Deployment
═══════════════════════════════════════════════════════
@@ -354,6 +359,9 @@ class Filter:
debug_mode: bool = Field(
default=True, description="Enable detailed logging for debugging."
)
show_debug_log: bool = Field(
default=False, description="Print debug logs to browser console (F12)"
)
def _save_summary(self, chat_id: str, summary: str, compressed_count: int):
"""Saves the summary to the database."""
@@ -515,12 +523,109 @@ class Filter:
return message
async def _emit_debug_log(
self,
__event_call__,
chat_id: str,
original_count: int,
compressed_count: int,
summary_length: int,
kept_first: int,
kept_last: int,
):
"""Emit debug log to browser console via JS execution"""
if not self.valves.show_debug_log or not __event_call__:
return
try:
# Prepare data for JS
log_data = {
"chatId": chat_id,
"originalCount": original_count,
"compressedCount": compressed_count,
"summaryLength": summary_length,
"keptFirst": kept_first,
"keptLast": kept_last,
"ratio": (
f"{(1 - compressed_count/original_count)*100:.1f}%"
if original_count > 0
else "0%"
),
}
# Construct JS code
js_code = f"""
(async function() {{
console.group("🗜️ Async Context Compression Debug");
console.log("Chat ID:", {json.dumps(chat_id)});
console.log("Messages:", {original_count} + " -> " + {compressed_count});
console.log("Compression Ratio:", {json.dumps(log_data['ratio'])});
console.log("Summary Length:", {summary_length} + " chars");
console.log("Configuration:", {{
"Keep First": {kept_first},
"Keep Last": {kept_last}
}});
console.groupEnd();
}})();
"""
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
except Exception as e:
print(f"Error emitting debug log: {e}")
async def _log(self, message: str, type: str = "info", event_call=None):
"""Unified logging to both backend (print) and frontend (console.log)"""
# Backend logging
if self.valves.debug_mode:
print(message)
# Frontend logging
if self.valves.show_debug_log and event_call:
try:
css = "color: #3b82f6;" # Blue default
if type == "error":
css = "color: #ef4444; font-weight: bold;" # Red
elif type == "warning":
css = "color: #f59e0b;" # Orange
elif type == "success":
css = "color: #10b981; font-weight: bold;" # Green
# Clean message for frontend: remove separators and extra newlines
lines = message.split("\n")
# Keep lines that don't start with lots of equals or hyphens
filtered_lines = [
line
for line in lines
if not line.strip().startswith("====")
and not line.strip().startswith("----")
]
clean_message = "\n".join(filtered_lines).strip()
if not clean_message:
return
# Escape quotes in message for JS string
safe_message = clean_message.replace('"', '\\"').replace("\n", "\\n")
js_code = f"""
console.log("%c[Compression] {safe_message}", "{css}");
"""
await event_call({"type": "execute", "data": {"code": js_code}})
except Exception as e:
print(f"Failed to emit log to frontend: {e}")
async def inlet(
self,
body: dict,
__user__: Optional[dict] = None,
__metadata__: dict = None,
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
) -> dict:
"""
Executed before sending to the LLM.
@@ -529,10 +634,11 @@ class Filter:
messages = body.get("messages", [])
chat_id = __metadata__["chat_id"]
if self.valves.debug_mode:
print(f"\n{'='*60}")
print(f"[Inlet] Chat ID: {chat_id}")
print(f"[Inlet] Received {len(messages)} messages")
if self.valves.debug_mode or self.valves.show_debug_log:
await self._log(
f"\n{'='*60}\n[Inlet] Chat ID: {chat_id}\n[Inlet] Received {len(messages)} messages",
event_call=__event_call__,
)
# Record the target compression progress for the original messages, for use in outlet
# Target is to compress up to the (total - keep_last) message
@@ -540,17 +646,18 @@ class Filter:
# [Optimization] Simple state cleanup check
if chat_id in self.temp_state:
if self.valves.debug_mode:
print(
f"[Inlet] ⚠️ Overwriting unconsumed old state (Chat ID: {chat_id})"
)
await self._log(
f"[Inlet] ⚠️ Overwriting unconsumed old state (Chat ID: {chat_id})",
type="warning",
event_call=__event_call__,
)
self.temp_state[chat_id] = target_compressed_count
if self.valves.debug_mode:
print(
f"[Inlet] Recorded target compression progress: {target_compressed_count}"
)
await self._log(
f"[Inlet] Recorded target compression progress: {target_compressed_count}",
event_call=__event_call__,
)
# Load summary record
summary_record = await asyncio.to_thread(self._load_summary_record, chat_id)
@@ -599,19 +706,32 @@ class Filter:
}
)
if self.valves.debug_mode:
print(
f"[Inlet] Applied summary: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})"
)
await self._log(
f"[Inlet] Applied summary: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})",
type="success",
event_call=__event_call__,
)
# Emit debug log to frontend (Keep the structured log as well)
await self._emit_debug_log(
__event_call__,
chat_id,
len(messages),
len(final_messages),
len(summary_record.summary),
self.valves.keep_first,
self.valves.keep_last,
)
else:
# No summary, use original messages
final_messages = messages
body["messages"] = final_messages
if self.valves.debug_mode:
print(f"[Inlet] Final send: {len(body['messages'])} messages")
print(f"{'='*60}\n")
await self._log(
f"[Inlet] Final send: {len(body['messages'])} messages\n{'='*60}\n",
event_call=__event_call__,
)
return body
@@ -621,6 +741,7 @@ class Filter:
__user__: Optional[dict] = None,
__metadata__: dict = None,
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
) -> dict:
"""
Executed after the LLM response is complete.
@@ -629,21 +750,23 @@ class Filter:
chat_id = __metadata__["chat_id"]
model = body.get("model", "gpt-3.5-turbo")
if self.valves.debug_mode:
print(f"\n{'='*60}")
print(f"[Outlet] Chat ID: {chat_id}")
print(f"[Outlet] Response complete")
if self.valves.debug_mode or self.valves.show_debug_log:
await self._log(
f"\n{'='*60}\n[Outlet] Chat ID: {chat_id}\n[Outlet] Response complete",
event_call=__event_call__,
)
# Process Token calculation and summary generation asynchronously in the background (do not wait for completion, do not affect output)
asyncio.create_task(
self._check_and_generate_summary_async(
chat_id, model, body, __user__, __event_emitter__
chat_id, model, body, __user__, __event_emitter__, __event_call__
)
)
if self.valves.debug_mode:
print(f"[Outlet] Background processing started")
print(f"{'='*60}\n")
await self._log(
f"[Outlet] Background processing started\n{'='*60}\n",
event_call=__event_call__,
)
return body
@@ -654,6 +777,7 @@ class Filter:
body: dict,
user_data: Optional[dict],
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
):
"""
Background processing: Calculates Token count and generates summary (does not block response).
@@ -667,36 +791,50 @@ class Filter:
"compression_threshold_tokens", self.valves.compression_threshold_tokens
)
if self.valves.debug_mode:
print(f"\n[🔍 Background Calculation] Starting Token count...")
await self._log(
f"\n[🔍 Background Calculation] Starting Token count...",
event_call=__event_call__,
)
# Calculate Token count in a background thread
current_tokens = await asyncio.to_thread(
self._calculate_messages_tokens, messages
)
if self.valves.debug_mode:
print(f"[🔍 Background Calculation] Token count: {current_tokens}")
await self._log(
f"[🔍 Background Calculation] Token count: {current_tokens}",
event_call=__event_call__,
)
# Check if compression is needed
if current_tokens >= compression_threshold_tokens:
if self.valves.debug_mode:
print(
f"[🔍 Background Calculation] ⚡ Compression threshold triggered (Token: {current_tokens} >= {compression_threshold_tokens})"
)
await self._log(
f"[🔍 Background Calculation] ⚡ Compression threshold triggered (Token: {current_tokens} >= {compression_threshold_tokens})",
type="warning",
event_call=__event_call__,
)
# Proceed to generate summary
await self._generate_summary_async(
messages, chat_id, body, user_data, __event_emitter__
messages,
chat_id,
body,
user_data,
__event_emitter__,
__event_call__,
)
else:
if self.valves.debug_mode:
print(
f"[🔍 Background Calculation] Compression threshold not reached (Token: {current_tokens} < {compression_threshold_tokens})"
)
await self._log(
f"[🔍 Background Calculation] Compression threshold not reached (Token: {current_tokens} < {compression_threshold_tokens})",
event_call=__event_call__,
)
except Exception as e:
print(f"[🔍 Background Calculation] ❌ Error: {str(e)}")
await self._log(
f"[🔍 Background Calculation] ❌ Error: {str(e)}",
type="error",
event_call=__event_call__,
)
async def _generate_summary_async(
self,
@@ -705,6 +843,7 @@ class Filter:
body: dict,
user_data: Optional[dict],
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
):
"""
Generates summary asynchronously (runs in background, does not block response).
@@ -714,18 +853,20 @@ class Filter:
3. Generate summary for the remaining middle messages.
"""
try:
if self.valves.debug_mode:
print(f"\n[🤖 Async Summary Task] Starting...")
await self._log(
f"\n[🤖 Async Summary Task] Starting...", event_call=__event_call__
)
# 1. Get target compression progress
# Prioritize getting from temp_state (calculated by inlet). If unavailable (e.g., after restart), assume current is full history.
target_compressed_count = self.temp_state.pop(chat_id, None)
if target_compressed_count is None:
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] ⚠️ Could not get inlet state, estimating progress using current message count: {target_compressed_count}"
)
await self._log(
f"[🤖 Async Summary Task] ⚠️ Could not get inlet state, estimating progress using current message count: {target_compressed_count}",
type="warning",
event_call=__event_call__,
)
# 2. Determine the range of messages to compress (Middle)
start_index = self.valves.keep_first
@@ -735,18 +876,18 @@ class Filter:
# Ensure indices are valid
if start_index >= end_index:
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] Middle messages empty (Start: {start_index}, End: {end_index}), skipping"
)
await self._log(
f"[🤖 Async Summary Task] Middle messages empty (Start: {start_index}, End: {end_index}), skipping",
event_call=__event_call__,
)
return
middle_messages = messages[start_index:end_index]
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] Middle messages to process: {len(middle_messages)}"
)
await self._log(
f"[🤖 Async Summary Task] Middle messages to process: {len(middle_messages)}",
event_call=__event_call__,
)
# 3. Check Token limit and truncate (Max Context Truncation)
# [Optimization] Use the summary model's (if any) threshold to decide how many middle messages can be processed
@@ -761,22 +902,26 @@ class Filter:
"max_context_tokens", self.valves.max_context_tokens
)
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] Using max limit for model {summary_model_id}: {max_context_tokens} Tokens"
)
# Calculate current total Tokens (using summary model for counting)
total_tokens = await asyncio.to_thread(
self._calculate_messages_tokens, messages
await self._log(
f"[🤖 Async Summary Task] Using max limit for model {summary_model_id}: {max_context_tokens} Tokens",
event_call=__event_call__,
)
if total_tokens > max_context_tokens:
excess_tokens = total_tokens - max_context_tokens
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] ⚠️ Total Tokens ({total_tokens}) exceed summary model limit ({max_context_tokens}), need to remove approx {excess_tokens} Tokens"
)
# Calculate tokens for middle messages only (plus buffer for prompt)
# We only send middle_messages to the summary model, so we shouldn't count the full history against its limit.
middle_tokens = await asyncio.to_thread(
self._calculate_messages_tokens, middle_messages
)
# Add buffer for prompt and output (approx 2000 tokens)
estimated_input_tokens = middle_tokens + 2000
if estimated_input_tokens > max_context_tokens:
excess_tokens = estimated_input_tokens - max_context_tokens
await self._log(
f"[🤖 Async Summary Task] ⚠️ Middle messages ({middle_tokens} Tokens) + Buffer exceed summary model limit ({max_context_tokens}), need to remove approx {excess_tokens} Tokens",
type="warning",
event_call=__event_call__,
)
# Remove from the head of middle_messages
removed_tokens = 0
@@ -784,20 +929,22 @@ class Filter:
while removed_tokens < excess_tokens and middle_messages:
msg_to_remove = middle_messages.pop(0)
msg_tokens = self._count_tokens(str(msg_to_remove.get("content", "")))
msg_tokens = self._count_tokens(
str(msg_to_remove.get("content", ""))
)
removed_tokens += msg_tokens
removed_count += 1
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] Removed {removed_count} messages, totaling {removed_tokens} Tokens"
)
await self._log(
f"[🤖 Async Summary Task] Removed {removed_count} messages, totaling {removed_tokens} Tokens",
event_call=__event_call__,
)
if not middle_messages:
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] Middle messages empty after truncation, skipping summary generation"
)
await self._log(
f"[🤖 Async Summary Task] Middle messages empty after truncation, skipping summary generation",
event_call=__event_call__,
)
return
# 4. Build conversation text
@@ -819,14 +966,14 @@ class Filter:
)
new_summary = await self._call_summary_llm(
None, conversation_text, body, user_data
None, conversation_text, body, user_data, __event_call__
)
# 6. Save new summary
if self.valves.debug_mode:
print(
"[Optimization] Saving summary in a background thread to avoid blocking the event loop."
)
await self._log(
"[Optimization] Saving summary in a background thread to avoid blocking the event loop.",
event_call=__event_call__,
)
await asyncio.to_thread(
self._save_summary, chat_id, new_summary, target_compressed_count
@@ -844,16 +991,22 @@ class Filter:
}
)
if self.valves.debug_mode:
print(
f"[🤖 Async Summary Task] ✅ Complete! New summary length: {len(new_summary)} characters"
)
print(
f"[🤖 Async Summary Task] Progress update: Compressed up to original message {target_compressed_count}"
)
await self._log(
f"[🤖 Async Summary Task] ✅ Complete! New summary length: {len(new_summary)} characters",
type="success",
event_call=__event_call__,
)
await self._log(
f"[🤖 Async Summary Task] Progress update: Compressed up to original message {target_compressed_count}",
event_call=__event_call__,
)
except Exception as e:
print(f"[🤖 Async Summary Task] ❌ Error: {str(e)}")
await self._log(
f"[🤖 Async Summary Task] ❌ Error: {str(e)}",
type="error",
event_call=__event_call__,
)
import traceback
traceback.print_exc()
@@ -890,12 +1043,15 @@ class Filter:
new_conversation_text: str,
body: dict,
user_data: dict,
__event_call__: Callable[[Any], Awaitable[None]] = None,
) -> str:
"""
Calls the LLM to generate a summary using Open WebUI's built-in method.
"""
if self.valves.debug_mode:
print(f"[🤖 LLM Call] Using Open WebUI's built-in method")
await self._log(
f"[🤖 LLM Call] Using Open WebUI's built-in method",
event_call=__event_call__,
)
# Build summary prompt (Optimized)
summary_prompt = f"""
@@ -934,8 +1090,7 @@ Based on the content above, generate the summary:
# Determine the model to use
model = self.valves.summary_model or body.get("model", "")
if self.valves.debug_mode:
print(f"[🤖 LLM Call] Model: {model}")
await self._log(f"[🤖 LLM Call] Model: {model}", event_call=__event_call__)
# Build payload
payload = {
@@ -953,18 +1108,19 @@ Based on the content above, generate the summary:
raise ValueError("Could not get user ID")
# [Optimization] Get user object in a background thread to avoid blocking the event loop.
if self.valves.debug_mode:
print(
"[Optimization] Getting user object in a background thread to avoid blocking the event loop."
)
await self._log(
"[Optimization] Getting user object in a background thread to avoid blocking the event loop.",
event_call=__event_call__,
)
user = await asyncio.to_thread(Users.get_user_by_id, user_id)
if not user:
raise ValueError(f"Could not find user: {user_id}")
if self.valves.debug_mode:
print(f"[🤖 LLM Call] User: {user.email}")
print(f"[🤖 LLM Call] Sending request...")
await self._log(
f"[🤖 LLM Call] User: {user.email}\n[🤖 LLM Call] Sending request...",
event_call=__event_call__,
)
# Create Request object
request = Request(scope={"type": "http", "app": webui_app})
@@ -977,8 +1133,11 @@ Based on the content above, generate the summary:
summary = response["choices"][0]["message"]["content"].strip()
if self.valves.debug_mode:
print(f"[🤖 LLM Call] ✅ Successfully received summary")
await self._log(
f"[🤖 LLM Call] ✅ Successfully received summary",
type="success",
event_call=__event_call__,
)
return summary
@@ -990,7 +1149,10 @@ Based on the content above, generate the summary:
"If this is a pipeline (Pipe) model or an incompatible model, please specify a compatible summary model (e.g., 'gemini-2.5-flash') in the configuration."
)
if self.valves.debug_mode:
print(f"[🤖 LLM Call] ❌ {error_message}")
await self._log(
f"[🤖 LLM Call] ❌ {error_message}",
type="error",
event_call=__event_call__,
)
raise Exception(error_message)

View File

@@ -5,7 +5,8 @@ author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
description: 通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。
version: 1.1.0
version: 1.1.1
openwebui_id: 5c0617cb-a9e4-4bd6-a440-d276534ebd18
license: MIT
═══════════════════════════════════════════════════════════════════════════════
@@ -137,6 +138,10 @@ debug_mode (调试模式)
默认: true
说明: 在日志中打印详细的调试信息。生产环境建议设为 `false`。
show_debug_log (前端调试日志)
默认: false
说明: 在浏览器控制台打印调试日志 (F12)。便于前端调试。
🔧 部署配置
═══════════════════════════════════════════════════════
@@ -344,6 +349,9 @@ class Filter:
default=0.1, ge=0.0, le=2.0, description="摘要生成的温度参数"
)
debug_mode: bool = Field(default=True, description="调试模式,打印详细日志")
show_debug_log: bool = Field(
default=False, description="在浏览器控制台打印调试日志 (F12)"
)
def _save_summary(self, chat_id: str, summary: str, compressed_count: int):
"""保存摘要到数据库"""
@@ -425,9 +433,7 @@ class Filter:
# 回退策略:粗略估算 (1 token ≈ 4 chars)
return len(text) // 4
def _calculate_messages_tokens(
self, messages: List[Dict]
) -> int:
def _calculate_messages_tokens(self, messages: List[Dict]) -> int:
"""计算消息列表的总 Token 数"""
total_tokens = 0
for msg in messages:
@@ -501,12 +507,109 @@ class Filter:
return message
async def _emit_debug_log(
self,
__event_call__,
chat_id: str,
original_count: int,
compressed_count: int,
summary_length: int,
kept_first: int,
kept_last: int,
):
"""Emit debug log to browser console via JS execution"""
if not self.valves.show_debug_log or not __event_call__:
return
try:
# Prepare data for JS
log_data = {
"chatId": chat_id,
"originalCount": original_count,
"compressedCount": compressed_count,
"summaryLength": summary_length,
"keptFirst": kept_first,
"keptLast": kept_last,
"ratio": (
f"{(1 - compressed_count/original_count)*100:.1f}%"
if original_count > 0
else "0%"
),
}
# Construct JS code
js_code = f"""
(async function() {{
console.group("🗜️ Async Context Compression Debug");
console.log("Chat ID:", {json.dumps(chat_id)});
console.log("Messages:", {original_count} + " -> " + {compressed_count});
console.log("Compression Ratio:", {json.dumps(log_data['ratio'])});
console.log("Summary Length:", {summary_length} + " chars");
console.log("Configuration:", {{
"Keep First": {kept_first},
"Keep Last": {kept_last}
}});
console.groupEnd();
}})();
"""
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
except Exception as e:
print(f"Error emitting debug log: {e}")
async def _log(self, message: str, type: str = "info", event_call=None):
"""统一日志输出到后端 (print) 和前端 (console.log)"""
# 后端日志
if self.valves.debug_mode:
print(message)
# 前端日志
if self.valves.show_debug_log and event_call:
try:
css = "color: #3b82f6;" # 默认蓝色
if type == "error":
css = "color: #ef4444; font-weight: bold;" # 红色
elif type == "warning":
css = "color: #f59e0b;" # 橙色
elif type == "success":
css = "color: #10b981; font-weight: bold;" # 绿色
# 清理前端消息:移除分隔符和多余换行
lines = message.split("\n")
# 保留不以大量等号或连字符开头的行
filtered_lines = [
line
for line in lines
if not line.strip().startswith("====")
and not line.strip().startswith("----")
]
clean_message = "\n".join(filtered_lines).strip()
if not clean_message:
return
# 转义消息中的引号和换行符
safe_message = clean_message.replace('"', '\\"').replace("\n", "\\n")
js_code = f"""
console.log("%c[压缩] {safe_message}", "{css}");
"""
await event_call({"type": "execute", "data": {"code": js_code}})
except Exception as e:
print(f"发送前端日志失败: {e}")
async def inlet(
self,
body: dict,
__user__: Optional[dict] = None,
__metadata__: dict = None,
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
) -> dict:
"""
在发送到 LLM 之前执行
@@ -515,10 +618,11 @@ class Filter:
messages = body.get("messages", [])
chat_id = __metadata__["chat_id"]
if self.valves.debug_mode:
print(f"\n{'='*60}")
print(f"[Inlet] Chat ID: {chat_id}")
print(f"[Inlet] 收到 {len(messages)} 条消息")
if self.valves.debug_mode or self.valves.show_debug_log:
await self._log(
f"\n{'='*60}\n[Inlet] Chat ID: {chat_id}\n[Inlet] 收到 {len(messages)} 条消息",
event_call=__event_call__,
)
# 记录原始消息的目标压缩进度,供 outlet 使用
# 目标是压缩到倒数第 keep_last 条之前
@@ -526,13 +630,18 @@ class Filter:
# [优化] 简单的状态清理检查
if chat_id in self.temp_state:
if self.valves.debug_mode:
print(f"[Inlet] ⚠️ 覆盖未消费的旧状态 (Chat ID: {chat_id})")
await self._log(
f"[Inlet] ⚠️ 覆盖未消费的旧状态 (Chat ID: {chat_id})",
type="warning",
event_call=__event_call__,
)
self.temp_state[chat_id] = target_compressed_count
if self.valves.debug_mode:
print(f"[Inlet] 记录目标压缩进度: {target_compressed_count}")
await self._log(
f"[Inlet] 记录目标压缩进度: {target_compressed_count}",
event_call=__event_call__,
)
# 加载摘要记录
summary_record = await asyncio.to_thread(self._load_summary_record, chat_id)
@@ -581,19 +690,32 @@ class Filter:
}
)
if self.valves.debug_mode:
print(
f"[Inlet] 应用摘要: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})"
)
await self._log(
f"[Inlet] 应用摘要: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})",
type="success",
event_call=__event_call__,
)
# Emit debug log to frontend (Keep the structured log as well)
await self._emit_debug_log(
__event_call__,
chat_id,
len(messages),
len(final_messages),
len(summary_record.summary),
self.valves.keep_first,
self.valves.keep_last,
)
else:
# 没有摘要,使用原始消息
final_messages = messages
body["messages"] = final_messages
if self.valves.debug_mode:
print(f"[Inlet] 最终发送: {len(body['messages'])} 条消息")
print(f"{'='*60}\n")
await self._log(
f"[Inlet] 最终发送: {len(body['messages'])} 条消息\n{'='*60}\n",
event_call=__event_call__,
)
return body
@@ -603,6 +725,7 @@ class Filter:
__user__: Optional[dict] = None,
__metadata__: dict = None,
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
) -> dict:
"""
在 LLM 响应完成后执行
@@ -611,21 +734,23 @@ class Filter:
chat_id = __metadata__["chat_id"]
model = body.get("model", "gpt-3.5-turbo")
if self.valves.debug_mode:
print(f"\n{'='*60}")
print(f"[Outlet] Chat ID: {chat_id}")
print(f"[Outlet] 响应完成")
if self.valves.debug_mode or self.valves.show_debug_log:
await self._log(
f"\n{'='*60}\n[Outlet] Chat ID: {chat_id}\n[Outlet] 响应完成",
event_call=__event_call__,
)
# 在后台异步处理 Token 计算和摘要生成(不等待完成,不影响输出)
asyncio.create_task(
self._check_and_generate_summary_async(
chat_id, model, body, __user__, __event_emitter__
chat_id, model, body, __user__, __event_emitter__, __event_call__
)
)
if self.valves.debug_mode:
print(f"[Outlet] 后台处理已启动")
print(f"{'='*60}\n")
await self._log(
f"[Outlet] 后台处理已启动\n{'='*60}\n",
event_call=__event_call__,
)
return body
@@ -636,6 +761,7 @@ class Filter:
body: dict,
user_data: Optional[dict],
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
):
"""
后台处理:计算 Token 数并生成摘要(不阻塞响应)
@@ -649,36 +775,50 @@ class Filter:
"compression_threshold_tokens", self.valves.compression_threshold_tokens
)
if self.valves.debug_mode:
print(f"\n[🔍 后台计算] 开始 Token 计数...")
await self._log(
f"\n[🔍 后台计算] 开始 Token 计数...",
event_call=__event_call__,
)
# 在后台线程中计算 Token 数
current_tokens = await asyncio.to_thread(
self._calculate_messages_tokens, messages
)
if self.valves.debug_mode:
print(f"[🔍 后台计算] Token 数: {current_tokens}")
await self._log(
f"[🔍 后台计算] Token 数: {current_tokens}",
event_call=__event_call__,
)
# 检查是否需要压缩
if current_tokens >= compression_threshold_tokens:
if self.valves.debug_mode:
print(
f"[🔍 后台计算] ⚡ 触发压缩阈值 (Token: {current_tokens} >= {compression_threshold_tokens})"
)
await self._log(
f"[🔍 后台计算] ⚡ 触发压缩阈值 (Token: {current_tokens} >= {compression_threshold_tokens})",
type="warning",
event_call=__event_call__,
)
# 继续生成摘要
await self._generate_summary_async(
messages, chat_id, body, user_data, __event_emitter__
messages,
chat_id,
body,
user_data,
__event_emitter__,
__event_call__,
)
else:
if self.valves.debug_mode:
print(
f"[🔍 后台计算] 未触发压缩阈值 (Token: {current_tokens} < {compression_threshold_tokens})"
)
await self._log(
f"[🔍 后台计算] 未触发压缩阈值 (Token: {current_tokens} < {compression_threshold_tokens})",
event_call=__event_call__,
)
except Exception as e:
print(f"[🔍 后台计算] ❌ 错误: {str(e)}")
await self._log(
f"[🔍 后台计算] ❌ 错误: {str(e)}",
type="error",
event_call=__event_call__,
)
async def _generate_summary_async(
self,
@@ -687,6 +827,7 @@ class Filter:
body: dict,
user_data: Optional[dict],
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
__event_call__: Callable[[Any], Awaitable[None]] = None,
):
"""
异步生成摘要(后台执行,不阻塞响应)
@@ -696,18 +837,18 @@ class Filter:
3. 对剩余的中间消息生成摘要。
"""
try:
if self.valves.debug_mode:
print(f"\n[🤖 异步摘要任务] 开始...")
await self._log(f"\n[🤖 异步摘要任务] 开始...", event_call=__event_call__)
# 1. 获取目标压缩进度
# 优先从 temp_state 获取(由 inlet 计算),如果获取不到(例如重启后),则假设当前是完整历史
target_compressed_count = self.temp_state.pop(chat_id, None)
if target_compressed_count is None:
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
if self.valves.debug_mode:
print(
f"[🤖 异步摘要任务] ⚠️ 无法获取 inlet 状态,使用当前消息数估算进度: {target_compressed_count}"
)
await self._log(
f"[🤖 异步摘要任务] ⚠️ 无法获取 inlet 状态,使用当前消息数估算进度: {target_compressed_count}",
type="warning",
event_call=__event_call__,
)
# 2. 确定待压缩的消息范围 (Middle)
start_index = self.valves.keep_first
@@ -717,16 +858,18 @@ class Filter:
# 确保索引有效
if start_index >= end_index:
if self.valves.debug_mode:
print(
f"[🤖 异步摘要任务] 中间消息为空 (Start: {start_index}, End: {end_index}),跳过"
)
await self._log(
f"[🤖 异步摘要任务] 中间消息为空 (Start: {start_index}, End: {end_index}),跳过",
event_call=__event_call__,
)
return
middle_messages = messages[start_index:end_index]
if self.valves.debug_mode:
print(f"[🤖 异步摘要任务] 待处理中间消息: {len(middle_messages)}")
await self._log(
f"[🤖 异步摘要任务] 待处理中间消息: {len(middle_messages)}",
event_call=__event_call__,
)
# 3. 检查 Token 上限并截断 (Max Context Truncation)
# [优化] 使用摘要模型(如果有)的阈值来决定能处理多少中间消息
@@ -739,22 +882,26 @@ class Filter:
"max_context_tokens", self.valves.max_context_tokens
)
if self.valves.debug_mode:
print(
f"[🤖 异步摘要任务] 使用模型 {summary_model_id} 的上限: {max_context_tokens} Tokens"
)
# 计算当前总 Token (使用摘要模型进行计数)
total_tokens = await asyncio.to_thread(
self._calculate_messages_tokens, messages
await self._log(
f"[🤖 异步摘要任务] 使用模型 {summary_model_id} 的上限: {max_context_tokens} Tokens",
event_call=__event_call__,
)
if total_tokens > max_context_tokens:
excess_tokens = total_tokens - max_context_tokens
if self.valves.debug_mode:
print(
f"[🤖 异步摘要任务] ⚠️ 总 Token ({total_tokens}) 超过摘要模型上限 ({max_context_tokens}),需要移除约 {excess_tokens} Token"
)
# 计算中间消息的 Token (加上提示词的缓冲)
# 我们只把 middle_messages 发送给摘要模型,所以不应该把完整历史计入限制
middle_tokens = await asyncio.to_thread(
self._calculate_messages_tokens, middle_messages
)
# 增加提示词和输出的缓冲 (约 2000 Tokens)
estimated_input_tokens = middle_tokens + 2000
if estimated_input_tokens > max_context_tokens:
excess_tokens = estimated_input_tokens - max_context_tokens
await self._log(
f"[🤖 异步摘要任务] ⚠️ 中间消息 ({middle_tokens} Tokens) + 缓冲超过摘要模型上限 ({max_context_tokens}),需要移除约 {excess_tokens} Token",
type="warning",
event_call=__event_call__,
)
# 从 middle_messages 头部开始移除
removed_tokens = 0
@@ -768,14 +915,16 @@ class Filter:
removed_tokens += msg_tokens
removed_count += 1
if self.valves.debug_mode:
print(
f"[🤖 异步摘要任务] 已移除 {removed_count} 条消息,共 {removed_tokens} Token"
)
await self._log(
f"[🤖 异步摘要任务] 已移除 {removed_count} 条消息,共 {removed_tokens} Token",
event_call=__event_call__,
)
if not middle_messages:
if self.valves.debug_mode:
print(f"[🤖 异步摘要任务] 截断后中间消息为空,跳过摘要生成")
await self._log(
f"[🤖 异步摘要任务] 截断后中间消息为空,跳过摘要生成",
event_call=__event_call__,
)
return
# 4. 构建对话文本
@@ -797,12 +946,14 @@ class Filter:
)
new_summary = await self._call_summary_llm(
None, conversation_text, body, user_data
None, conversation_text, body, user_data, __event_call__
)
# 6. 保存新摘要
if self.valves.debug_mode:
print("[优化] 在后台线程中保存摘要以避免阻塞事件循环。")
await self._log(
"[优化] 在后台线程中保存摘要以避免阻塞事件循环。",
event_call=__event_call__,
)
await asyncio.to_thread(
self._save_summary, chat_id, new_summary, target_compressed_count
@@ -814,32 +965,40 @@ class Filter:
{
"type": "status",
"data": {
"description": f"上下文摘要已更新 (压缩 {len(middle_messages)} 条消息)",
"description": f"上下文摘要已更新 (压缩 {len(middle_messages)} 条消息)",
"done": True,
},
}
)
if self.valves.debug_mode:
print(f"[🤖 异步摘要任务] ✅ 完成!新摘要长度: {len(new_summary)} 字符")
print(
f"[🤖 异步摘要任务] 进度更新: 已压缩至原始第 {target_compressed_count} 条消息"
)
await self._log(
f"[🤖 异步摘要任务] ✅ 完成!新摘要长度: {len(new_summary)} 字符",
type="success",
event_call=__event_call__,
)
await self._log(
f"[🤖 异步摘要任务] 进度更新: 已压缩至原始消息 {target_compressed_count}",
event_call=__event_call__,
)
except Exception as e:
print(f"[🤖 异步摘要任务] ❌ 错误: {str(e)}")
await self._log(
f"[🤖 异步摘要任务] ❌ 错误: {str(e)}",
type="error",
event_call=__event_call__,
)
import traceback
traceback.print_exc()
def _format_messages_for_summary(self, messages: list) -> str:
"""格式化消息用于摘要"""
"""Formats messages for summarization."""
formatted = []
for i, msg in enumerate(messages, 1):
role = msg.get("role", "unknown")
content = msg.get("content", "")
# 处理多模态内容
# Handle multimodal content
if isinstance(content, list):
text_parts = []
for part in content:
@@ -847,10 +1006,10 @@ class Filter:
text_parts.append(part.get("text", ""))
content = " ".join(text_parts)
# 处理角色名称
role_name = {"user": "用户", "assistant": "助手"}.get(role, role)
# Handle role name
role_name = {"user": "User", "assistant": "Assistant"}.get(role, role)
# 限制每条消息的长度,避免过长
# Limit length of each message to avoid excessive length
if len(content) > 500:
content = content[:500] + "..."
@@ -864,12 +1023,15 @@ class Filter:
new_conversation_text: str,
body: dict,
user_data: dict,
__event_call__: Callable[[Any], Awaitable[None]] = None,
) -> str:
"""
使用 Open WebUI 内置方法调用 LLM 生成摘要
调用 LLM 生成摘要,使用 Open Web UI 内置方法
"""
if self.valves.debug_mode:
print(f"[🤖 LLM 调用] 使用 Open WebUI 内置方法")
await self._log(
f"[🤖 LLM 调用] 使用 Open Web UI 内置方法",
event_call=__event_call__,
)
# 构建摘要提示词 (优化版)
summary_prompt = f"""
@@ -908,8 +1070,7 @@ class Filter:
# 确定使用的模型
model = self.valves.summary_model or body.get("model", "")
if self.valves.debug_mode:
print(f"[🤖 LLM 调用] 模型: {model}")
await self._log(f"[🤖 LLM 调用] 模型: {model}", event_call=__event_call__)
# 构建 payload
payload = {
@@ -926,17 +1087,20 @@ class Filter:
if not user_id:
raise ValueError("无法获取用户 ID")
# [优化] 在后台线程中获取用户对象以避免阻塞事件循环
if self.valves.debug_mode:
print("[优化] 在后台线程中获取用户对象以避免阻塞事件循环。")
# [优化] 在后台线程中获取用户对象以避免阻塞事件循环
await self._log(
"[优化] 在后台线程中获取用户对象以避免阻塞事件循环。",
event_call=__event_call__,
)
user = await asyncio.to_thread(Users.get_user_by_id, user_id)
if not user:
raise ValueError(f"无法找到用户: {user_id}")
if self.valves.debug_mode:
print(f"[🤖 LLM 调用] 用户: {user.email}")
print(f"[🤖 LLM 调用] 发送请求...")
await self._log(
f"[🤖 LLM 调用] 用户: {user.email}\n[🤖 LLM 调用] 发送请求...",
event_call=__event_call__,
)
# 创建 Request 对象
request = Request(scope={"type": "http", "app": webui_app})
@@ -949,8 +1113,11 @@ class Filter:
summary = response["choices"][0]["message"]["content"].strip()
if self.valves.debug_mode:
print(f"[🤖 LLM 调用] ✅ 成功获取摘要")
await self._log(
f"[🤖 LLM 调用] ✅ 成功接收摘要",
type="success",
event_call=__event_call__,
)
return summary
@@ -958,11 +1125,14 @@ class Filter:
error_message = f"调用 LLM ({model}) 生成摘要时发生错误: {str(e)}"
if not self.valves.summary_model:
error_message += (
"\n[提示] 您没有指定摘要模型 (summary_model),因此尝试使用当前对话的模型。"
"如果这是一个流水线Pipe模型或不兼容的模型,请在配置中指定一个兼容的摘要模型'gemini-2.5-flash'"
"\n[提示] 您未指定 summary_model因此过滤器尝试使用当前对话的模型。"
"如果这是流水线 (Pipe) 模型或不兼容的模型,请在配置中指定兼容的摘要模型 (例'gemini-2.5-flash')"
)
if self.valves.debug_mode:
print(f"[🤖 LLM 调用] ❌ {error_message}")
await self._log(
f"[🤖 LLM 调用] ❌ {error_message}",
type="error",
event_call=__event_call__,
)
raise Exception(error_message)

View File

@@ -1,12 +1,9 @@
"""
title: Context & Model Enhancement Filter
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.2
version: 0.3
description:
一个功能全面的 Filter 插件,用于增强请求上下文和优化模型功能。提供大核心功能:
一个专注于增强请求上下文和优化模型功能的 Filter 插件。提供大核心功能:
1. 环境变量注入:在每条用户消息前自动注入用户环境变量(用户名、时间、时区、语言等)
- 支持纯文本、图片、多模态消息
@@ -24,222 +21,24 @@ description:
- 动态模型重定向
- 智能化的模型识别和适配
4. 智能内容规范化:生产级的内容清洗与修复系统
- 智能修复损坏的代码块(前缀、后缀、缩进)
- 规范化 LaTeX 公式格式(行内/块级)
- 优化思维链标签(</thought>)格式
- 自动闭合未结束的代码块
- 智能列表格式修复
- 清理冗余的 XML 标签
- 可配置的规则系统
features:
- 自动化环境变量管理
- 智能模型功能适配
- 异步状态反馈
- 幂等性保证
- 多模型支持
- 智能内容清洗与规范化
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Callable
from typing import Optional
import re
import logging
from dataclasses import dataclass, field
import asyncio
# 配置日志
logger = logging.getLogger(__name__)
@dataclass
class NormalizerConfig:
"""规范化配置类,用于动态启用/禁用特定规则"""
enable_escape_fix: bool = True # 修复转义字符
enable_thought_tag_fix: bool = True # 修复思考链标签
enable_code_block_fix: bool = True # 修复代码块格式
enable_latex_fix: bool = True # 修复 LaTeX 公式格式
enable_list_fix: bool = False # 修复列表换行
enable_unclosed_block_fix: bool = True # 修复未闭合代码块
enable_fullwidth_symbol_fix: bool = False # 修复代码内的全角符号
enable_xml_tag_cleanup: bool = True # 清理 XML 残留标签
# 自定义清理函数列表(高级扩展用)
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
class ContentNormalizer:
"""LLM 输出内容规范化器 - 生产级实现"""
# --- 1. 预编译正则表达式(性能优化) ---
_PATTERNS = {
# 代码块前缀:如果 ``` 前面不是行首也不是换行符
'code_block_prefix': re.compile(r'(?<!^)(?<!\n)(```)', re.MULTILINE),
# 代码块后缀:匹配 ```语言名 后面紧跟非空白字符(没有换行)
# 匹配 ```python code 这种情况,但不匹配 ```python 或 ```python\n
'code_block_suffix': re.compile(r'(```[\w\+\-\.]*)[ \t]+([^\n\r])'),
# 代码块缩进:行首的空白字符 + ```
'code_block_indent': re.compile(r'^[ \t]+(```)', re.MULTILINE),
# 思考链标签:</thought> 后可能跟空格或换行
'thought_tag': re.compile(r'</thought>[ \t]*\n*'),
# LaTeX 块级公式:\[ ... \]
'latex_bracket_block': re.compile(r'\\\[(.+?)\\\]', re.DOTALL),
# LaTeX 行内公式:\( ... \)
'latex_paren_inline': re.compile(r'\\\((.+?)\\\)'),
# 列表项:非换行符 + 数字 + 点 + 空格 (e.g. "Text1. Item")
'list_item': re.compile(r'([^\n])(\d+\. )'),
# XML 残留标签 (如 Claude 的 artifacts)
'xml_artifacts': re.compile(r'</?(?:antArtifact|antThinking|artifact)[^>]*>', re.IGNORECASE),
}
def __init__(self, config: Optional[NormalizerConfig] = None):
self.config = config or NormalizerConfig()
self.applied_fixes = []
def normalize(self, content: str) -> str:
"""主入口:按顺序应用所有规范化规则"""
self.applied_fixes = []
if not content:
return content
try:
# 1. 转义字符修复(必须最先执行,否则影响后续正则)
if self.config.enable_escape_fix:
original = content
content = self._fix_escape_characters(content)
if content != original:
self.applied_fixes.append("修复转义字符")
# 2. 思考链标签规范化
if self.config.enable_thought_tag_fix:
original = content
content = self._fix_thought_tags(content)
if content != original:
self.applied_fixes.append("规范化思考链")
# 3. 代码块格式修复
if self.config.enable_code_block_fix:
original = content
content = self._fix_code_blocks(content)
if content != original:
self.applied_fixes.append("修复代码块格式")
# 4. LaTeX 公式规范化
if self.config.enable_latex_fix:
original = content
content = self._fix_latex_formulas(content)
if content != original:
self.applied_fixes.append("规范化 LaTeX 公式")
# 5. 列表格式修复
if self.config.enable_list_fix:
original = content
content = self._fix_list_formatting(content)
if content != original:
self.applied_fixes.append("修复列表格式")
# 6. 未闭合代码块检测与修复
if self.config.enable_unclosed_block_fix:
original = content
content = self._fix_unclosed_code_blocks(content)
if content != original:
self.applied_fixes.append("闭合未结束代码块")
# 7. 全角符号转半角(仅代码块内)
if self.config.enable_fullwidth_symbol_fix:
original = content
content = self._fix_fullwidth_symbols_in_code(content)
if content != original:
self.applied_fixes.append("全角符号转半角")
# 8. XML 标签残留清理
if self.config.enable_xml_tag_cleanup:
original = content
content = self._cleanup_xml_tags(content)
if content != original:
self.applied_fixes.append("清理 XML 标签")
# 9. 执行自定义清理函数
for cleaner in self.config.custom_cleaners:
original = content
content = cleaner(content)
if content != original:
self.applied_fixes.append("执行自定义清理")
return content
except Exception as e:
# 生产环境保底机制:如果清洗过程报错,返回原始内容,避免阻断服务
logger.error(f"内容规范化失败: {e}", exc_info=True)
return content
def _fix_escape_characters(self, content: str) -> str:
"""修复过度转义的字符"""
# 注意:先处理具体的转义序列,再处理通用的双反斜杠
content = content.replace("\\r\\n", "\n")
content = content.replace("\\n", "\n")
content = content.replace("\\t", "\t")
# 修复过度转义的反斜杠 (例如路径 C:\\Users)
content = content.replace("\\\\", "\\")
return content
def _fix_thought_tags(self, content: str) -> str:
"""规范化 </thought> 标签,统一为空两行"""
return self._PATTERNS['thought_tag'].sub("</thought>\n\n", content)
def _fix_code_blocks(self, content: str) -> str:
"""修复代码块格式(独占行、换行、去缩进)"""
# C: 移除代码块前的缩进(必须先执行,否则影响下面的判断)
content = self._PATTERNS['code_block_indent'].sub(r"\1", content)
# A: 确保 ``` 前有换行
content = self._PATTERNS['code_block_prefix'].sub(r"\n\1", content)
# B: 确保 ```语言标识 后有换行
content = self._PATTERNS['code_block_suffix'].sub(r"\1\n\2", content)
return content
def _fix_latex_formulas(self, content: str) -> str:
"""规范化 LaTeX 公式:\[ -> $$ (块级), \( -> $ (行内)"""
content = self._PATTERNS['latex_bracket_block'].sub(r"$$\1$$", content)
content = self._PATTERNS['latex_paren_inline'].sub(r"$\1$", content)
return content
def _fix_list_formatting(self, content: str) -> str:
"""修复列表项缺少换行的问题 (如 'text1. item' -> 'text\\n1. item')"""
return self._PATTERNS['list_item'].sub(r"\1\n\2", content)
def _fix_unclosed_code_blocks(self, content: str) -> str:
"""检测并修复未闭合的代码块"""
if content.count("```") % 2 != 0:
logger.warning("检测到未闭合的代码块,自动补全")
content += "\n```"
return content
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
"""在代码块内将全角符号转为半角(精细化操作)"""
# 常见误用的全角符号映射
FULLWIDTH_MAP = {
'': ',', '': '.', '': '(', '': ')',
'': '[', '': ']', '': ';', '': ':',
'': '?', '': '!', '"': '"', '"': '"',
''': "'", ''': "'",
}
parts = content.split("```")
# 代码块内容位于索引 1, 3, 5... (奇数位)
for i in range(1, len(parts), 2):
for full, half in FULLWIDTH_MAP.items():
parts[i] = parts[i].replace(full, half)
return "```".join(parts)
def _cleanup_xml_tags(self, content: str) -> str:
"""移除无关的 XML 标签"""
return self._PATTERNS['xml_artifacts'].sub("", content)
class Filter:
class Valves(BaseModel):
@@ -349,13 +148,9 @@ class Filter:
body["model"] = body["model"] + "-search"
features["web_search"] = False
search_enabled_for_model = True
if user_email == "yi204o@qq.com":
features["web_search"] = False
# 如果启用了模型本身的搜索能力,发送状态提示
if search_enabled_for_model and __event_emitter__:
import asyncio
try:
asyncio.create_task(
self._emit_search_status(__event_emitter__, model_name)
@@ -464,8 +259,6 @@ class Filter:
# 环境变量注入成功后,发送状态提示给用户
if env_injected and __event_emitter__:
import asyncio
try:
# 如果在异步环境中,使用 await
asyncio.create_task(self._emit_env_status(__event_emitter__))
@@ -506,67 +299,3 @@ class Filter:
)
except Exception as e:
print(f"发送搜索状态提示时出错: {e}")
async def _emit_normalization_status(self, __event_emitter__, applied_fixes: List[str] = None):
"""
发送内容规范化完成的状态提示
"""
description = "✓ 内容已自动规范化"
if applied_fixes:
description += f"{', '.join(applied_fixes)}"
try:
await __event_emitter__(
{
"type": "status",
"data": {
"description": description,
"done": True,
},
}
)
except Exception as e:
print(f"发送规范化状态提示时出错: {e}")
def _contains_html(self, content: str) -> bool:
"""
检测内容是否包含 HTML 标签
"""
# 匹配常见的 HTML 标签
pattern = r"<\s*/?\s*(?:html|head|body|div|span|p|br|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|b|i|strong|em|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
return bool(re.search(pattern, content, re.IGNORECASE))
def outlet(self, body: dict, __user__: Optional[dict] = None, __event_emitter__=None) -> dict:
"""
处理传出响应体,通过修改最后一条助手消息的内容。
使用 ContentNormalizer 进行全面的内容规范化。
"""
if "messages" in body and body["messages"]:
last = body["messages"][-1]
content = last.get("content", "") or ""
if last.get("role") == "assistant" and isinstance(content, str):
# 如果包含 HTML跳过规范化为了防止错误格式化
if self._contains_html(content):
return body
# 初始化规范化器
normalizer = ContentNormalizer()
# 执行规范化
new_content = normalizer.normalize(content)
# 更新内容
if new_content != content:
last["content"] = new_content
# 如果内容发生了改变,发送状态提示
if __event_emitter__:
import asyncio
try:
# 传入 applied_fixes
asyncio.create_task(self._emit_normalization_status(__event_emitter__, normalizer.applied_fixes))
except RuntimeError:
# 假如不在循环中,则忽略
pass
return body

View File

@@ -0,0 +1,162 @@
# Markdown Normalizer 功能详解
本插件旨在修复 LLM 输出中常见的 Markdown 格式问题,确保在 Open WebUI 中完美渲染。以下是支持的修复功能列表及示例。
## 1. 代码块修复 (Code Block Fixes)
### 1.1 去除代码块缩进
LLM 有时会在代码块前添加空格缩进,导致渲染失效。本插件会自动移除这些缩进。
**Before:**
```python
print("hello")
```
**After:**
```python
print("hello")
```
### 1.2 补全代码块前后换行
代码块标记 ` ``` ` 必须独占一行。如果 LLM 将其与文本混在一行,插件会自动修复。
**Before:**
Here is code:```python
print("hello")```
**After:**
Here is code:
```python
print("hello")
```
### 1.3 修复语言标识符后的换行
有时 LLM 会忘记在语言标识符(如 `python`)后换行。
**Before:**
```python print("hello")
```
**After:**
```python
print("hello")
```
### 1.4 自动闭合代码块
如果输出被截断或 LLM 忘记闭合代码块,插件会自动添加结尾的 ` ``` `
**Before:**
```python
print("unfinished code...")
**After:**
```python
print("unfinished code...")
```
## 2. LaTeX 公式规范化 (LaTeX Normalization)
Open WebUI 使用 MathJax/KaTeX 渲染公式,通常需要 `$$``$` 包裹。本插件会将常见的 LaTeX 括号语法转换为标准格式。
**Before:**
块级公式:\[ E = mc^2 \]
行内公式:\( a^2 + b^2 = c^2 \)
**After:**
块级公式:$$ E = mc^2 $$
行内公式:$ a^2 + b^2 = c^2 $
## 3. 转义字符清理 (Escape Character Fix)
修复过度转义的字符,这常见于某些 API 返回的原始字符串中。
**Before:**
Line 1\\nLine 2\\tTabbed
**After:**
Line 1
Line 2 Tabbed
## 4. 思维链标签规范化 (Thought Tag Fix)
**功能**:
1. 确保 `</thought>` 标签后有足够的空行,防止思维链内容与正文粘连。
2. **标准化标签**: 将 `<think>` (DeepSeek 等模型常用) 或 `<thinking>` 统一转换为 Open WebUI 标准的 `<thought>` 标签,以便正确触发 UI 的折叠功能。
**默认**: 开启 (`enable_thought_tag_fix = True`)
**示例**:
* **Before**: `<think>Thinking...</think>Response starts here.`
* **After**:
```xml
<thought>Thinking...</thought>
Response starts here.
```
## 5. 列表格式修复 (List Formatting Fix)
*默认关闭,需在设置中开启*
修复列表项缺少换行的问题。
**Before:**
Header1. Item 1
**After:**
Header
1. Item 1
## 6. 全角符号转半角 (Full-width Symbol Fix)
*默认关闭,需在设置中开启*
仅在**代码块内部**将全角符号转换为半角符号,防止代码因符号问题无法运行。
**Before:**
```python
if x == 1
print"hello"
```
**After:**
```python
if x == 1:
print("hello")
```
## 7. Mermaid 语法修复 (Mermaid Syntax Fix)
**功能**: 修复 Mermaid 图表中常见的语法错误,特别是未加引号的标签包含特殊字符的情况。
**默认**: 开启 (`enable_mermaid_fix = True`)
**示例**:
* **Before**:
```mermaid
graph TD
A[Label with (parens)] --> B(Label with [brackets])
```
* **After**:
```mermaid
graph TD
A["Label with (parens)"] --> B("Label with [brackets]")
```
## 8. XML 标签清理 (XML Cleanup)
移除 LLM 输出中残留的无用 XML 标签(如 Claude 的 artifact 标签)。
**Before:**
Here is the result <antArtifact>hidden metadata</antArtifact>.
**After:**
## 9. 标题格式修复 (Heading Format Fix)
**功能**: 修复标题标记 `#` 后缺少空格的问题。
**默认**: 开启 (`enable_heading_fix = True`)
**示例**:
* **Before**: `#Heading 1`
* **After**: `# Heading 1`
## 10. 表格格式修复 (Table Format Fix)
**功能**: 修复表格行末尾缺少管道符 `|` 的问题。
**默认**: 开启 (`enable_table_fix = True`)
**示例**:
* **Before**: `| Col 1 | Col 2`
* **After**: `| Col 1 | Col 2 |`

View File

@@ -0,0 +1,44 @@
# Markdown Normalizer Filter
A production-grade content normalizer filter for Open WebUI that fixes common Markdown formatting issues in LLM outputs. It ensures that code blocks, LaTeX formulas, Mermaid diagrams, and other Markdown elements are rendered correctly.
## Features
* **Mermaid Syntax Fix**: Automatically fixes common Mermaid syntax errors, such as unquoted node labels and unclosed subgraphs, ensuring diagrams render correctly.
* **Frontend Console Debugging**: Supports printing structured debug logs directly to the browser console (F12) for easier troubleshooting.
* **Code Block Formatting**: Fixes broken code block prefixes, suffixes, and indentation.
* **LaTeX Normalization**: Standardizes LaTeX formula delimiters (`\[` -> `$$`, `\(` -> `$`).
* **Thought Tag Normalization**: Unifies thought tags (`<think>`, `<thinking>` -> `<thought>`).
* **Escape Character Fix**: Cleans up excessive escape characters (`\\n`, `\\t`).
* **List Formatting**: Ensures proper newlines in list items.
* **Heading Fix**: Adds missing spaces in headings (`#Heading` -> `# Heading`).
* **Table Fix**: Adds missing closing pipes in tables.
* **XML Cleanup**: Removes leftover XML artifacts.
## Usage
1. Install the plugin in Open WebUI.
2. Enable the filter globally or for specific models.
3. Configure the enabled fixes in the **Valves** settings.
4. (Optional) Enable **Show Debug Log** in Valves to view detailed logs in the browser console.
## Configuration (Valves)
* `priority`: Filter priority (default: 50).
* `enable_escape_fix`: Fix excessive escape characters.
* `enable_thought_tag_fix`: Normalize thought tags.
* `enable_code_block_fix`: Fix code block formatting.
* `enable_latex_fix`: Normalize LaTeX formulas.
* `enable_list_fix`: Fix list item newlines (Experimental).
* `enable_unclosed_block_fix`: Auto-close unclosed code blocks.
* `enable_fullwidth_symbol_fix`: Fix full-width symbols in code blocks.
* `enable_mermaid_fix`: Fix Mermaid syntax errors.
* `enable_heading_fix`: Fix missing space in headings.
* `enable_table_fix`: Fix missing closing pipe in tables.
* `enable_xml_tag_cleanup`: Cleanup leftover XML tags.
* `show_status`: Show status notification when fixes are applied.
* `show_debug_log`: Print debug logs to browser console.
## License
MIT

View File

@@ -0,0 +1,44 @@
# Markdown 格式化过滤器 (Markdown Normalizer)
这是一个用于 Open WebUI 的生产级内容格式化过滤器,旨在修复 LLM 输出中常见的 Markdown 格式问题。它能确保代码块、LaTeX 公式、Mermaid 图表和其他 Markdown 元素被正确渲染。
## 功能特性
* **Mermaid 语法修复**: 自动修复常见的 Mermaid 语法错误,如未加引号的节点标签和未闭合的子图 (Subgraph),确保图表能正确渲染。
* **前端控制台调试**: 支持将结构化的调试日志直接打印到浏览器控制台 (F12),方便排查问题。
* **代码块格式化**: 修复破损的代码块前缀、后缀和缩进问题。
* **LaTeX 规范化**: 标准化 LaTeX 公式定界符 (`\[` -> `$$`, `\(` -> `$`)。
* **思维标签规范化**: 统一思维链标签 (`<think>`, `<thinking>` -> `<thought>`)。
* **转义字符修复**: 清理过度的转义字符 (`\\n`, `\\t`)。
* **列表格式化**: 确保列表项有正确的换行。
* **标题修复**: 修复标题中缺失的空格 (`#标题` -> `# 标题`)。
* **表格修复**: 修复表格中缺失的闭合管道符。
* **XML 清理**: 移除残留的 XML 标签。
## 使用方法
1. 在 Open WebUI 中安装此插件。
2. 全局启用或为特定模型启用此过滤器。
3.**Valves** 设置中配置需要启用的修复项。
4. (可选) 在 Valves 中开启 **显示调试日志 (Show Debug Log)** 以在浏览器控制台中查看详细日志。
## 配置项 (Valves)
* `priority`: 过滤器优先级 (默认: 50)。
* `enable_escape_fix`: 修复过度的转义字符。
* `enable_thought_tag_fix`: 规范化思维标签。
* `enable_code_block_fix`: 修复代码块格式。
* `enable_latex_fix`: 规范化 LaTeX 公式。
* `enable_list_fix`: 修复列表项换行 (实验性)。
* `enable_unclosed_block_fix`: 自动闭合未闭合的代码块。
* `enable_fullwidth_symbol_fix`: 修复代码块中的全角符号。
* `enable_mermaid_fix`: 修复 Mermaid 语法错误。
* `enable_heading_fix`: 修复标题中缺失的空格。
* `enable_table_fix`: 修复表格中缺失的闭合管道符。
* `enable_xml_tag_cleanup`: 清理残留的 XML 标签。
* `show_status`: 应用修复时显示状态通知。
* `show_debug_log`: 在浏览器控制台打印调试日志。
## 许可证
MIT

View File

@@ -0,0 +1,524 @@
"""
title: Markdown Normalizer
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 1.0.0
description: A production-grade content normalizer filter that fixes common Markdown formatting issues in LLM outputs, such as broken code blocks, LaTeX formulas, and list formatting.
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Callable
import re
import logging
import logging
import asyncio
import json
from dataclasses import dataclass, field
# Configure logging
logger = logging.getLogger(__name__)
@dataclass
class NormalizerConfig:
"""Configuration class for enabling/disabling specific normalization rules"""
enable_escape_fix: bool = True # Fix excessive escape characters
enable_thought_tag_fix: bool = True # Normalize thought tags
enable_code_block_fix: bool = True # Fix code block formatting
enable_latex_fix: bool = True # Fix LaTeX formula formatting
enable_list_fix: bool = (
False # Fix list item newlines (default off as it can be aggressive)
)
enable_unclosed_block_fix: bool = True # Auto-close unclosed code blocks
enable_fullwidth_symbol_fix: bool = False # Fix full-width symbols in code blocks
enable_mermaid_fix: bool = True # Fix common Mermaid syntax errors
enable_heading_fix: bool = (
True # Fix missing space in headings (#Header -> # Header)
)
enable_table_fix: bool = True # Fix missing closing pipe in tables
enable_xml_tag_cleanup: bool = True # Cleanup leftover XML tags
# Custom cleaner functions (for advanced extension)
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
class ContentNormalizer:
"""LLM Output Content Normalizer - Production Grade Implementation"""
# --- 1. Pre-compiled Regex Patterns (Performance Optimization) ---
_PATTERNS = {
# Code block prefix: if ``` is not at start of line or file
"code_block_prefix": re.compile(r"(?<!^)(?<!\n)(```)", re.MULTILINE),
# Code block suffix: ```lang followed by non-whitespace (no newline)
"code_block_suffix": re.compile(r"(```[\w\+\-\.]*)[ \t]+([^\n\r])"),
# Code block indent: whitespace at start of line + ```
"code_block_indent": re.compile(r"^[ \t]+(```)", re.MULTILINE),
# Thought tag: </thought> followed by optional whitespace/newlines
"thought_end": re.compile(
r"</(thought|think|thinking)>[ \t]*\n*", re.IGNORECASE
),
"thought_start": re.compile(r"<(thought|think|thinking)>", re.IGNORECASE),
# LaTeX block: \[ ... \]
"latex_bracket_block": re.compile(r"\\\[(.+?)\\\]", re.DOTALL),
# LaTeX inline: \( ... \)
"latex_paren_inline": re.compile(r"\\\((.+?)\\\)"),
# List item: non-newline + digit + dot + space
"list_item": re.compile(r"([^\n])(\d+\. )"),
# XML artifacts (e.g. Claude's)
"xml_artifacts": re.compile(
r"</?(?:antArtifact|antThinking|artifact)[^>]*>", re.IGNORECASE
),
# Mermaid: Match various node shapes and quote unquoted labels
# Fix "reverse optimization": Must precisely match shape delimiters to avoid breaking structure
# Priority: Longer delimiters match first
"mermaid_node": re.compile(
r'("[^"\\]*(?:\\.[^"\\]*)*")|' # Match quoted strings first (Group 1)
r"(\w+)\s*(?:"
r"(\(\(\()(?![\"])(.*?)(?<![\"])(\)\)\))|" # (((...))) Double Circle
r"(\(\()(?![\"])(.*?)(?<![\"])(\)\))|" # ((...)) Circle
r"(\(\[)(?![\"])(.*?)(?<![\"])(\]\))|" # ([...]) Stadium
r"(\[\()(?![\"])(.*?)(?<![\"])(\)\])|" # [(...)] Cylinder
r"(\[\[)(?![\"])(.*?)(?<![\"])(\]\])|" # [[...]] Subroutine
r"(\{\{)(?![\"])(.*?)(?<![\"])(\}\})|" # {{...}} Hexagon
r"(\[/)(?![\"])(.*?)(?<![\"])(/\])|" # [/.../] Parallelogram
r"(\[\\)(?![\"])(.*?)(?<![\"])(\\\])|" # [\...\] Parallelogram Alt
r"(\[/)(?![\"])(.*?)(?<![\"])(\\\])|" # [/...\] Trapezoid
r"(\[\\)(?![\"])(.*?)(?<![\"])(/\])|" # [\.../] Trapezoid Alt
r"(\()(?![\"])(.*?)(?<![\"])(\))|" # (...) Round
r"(\[)(?![\"])(.*?)(?<![\"])(\])|" # [...] Square
r"(\{)(?![\"])(.*?)(?<![\"])(\})|" # {...} Rhombus
r"(>)(?![\"])(.*?)(?<![\"])(\])" # >...] Asymmetric
r")"
),
# Heading: #Heading -> # Heading
"heading_space": re.compile(r"^(#+)([^ \n#])", re.MULTILINE),
# Table: | col1 | col2 -> | col1 | col2 |
"table_pipe": re.compile(r"^(\|.*[^|\n])$", re.MULTILINE),
}
def __init__(self, config: Optional[NormalizerConfig] = None):
self.config = config or NormalizerConfig()
self.applied_fixes = []
def normalize(self, content: str) -> str:
"""Main entry point: apply all normalization rules in order"""
self.applied_fixes = []
if not content:
return content
original_content = content # Keep a copy for logging
try:
# 1. Escape character fix (Must be first)
if self.config.enable_escape_fix:
original = content
content = self._fix_escape_characters(content)
if content != original:
self.applied_fixes.append("Fix Escape Chars")
# 2. Thought tag normalization
if self.config.enable_thought_tag_fix:
original = content
content = self._fix_thought_tags(content)
if content != original:
self.applied_fixes.append("Normalize Thought Tags")
# 3. Code block formatting fix
if self.config.enable_code_block_fix:
original = content
content = self._fix_code_blocks(content)
if content != original:
self.applied_fixes.append("Fix Code Blocks")
# 4. LaTeX formula normalization
if self.config.enable_latex_fix:
original = content
content = self._fix_latex_formulas(content)
if content != original:
self.applied_fixes.append("Normalize LaTeX")
# 5. List formatting fix
if self.config.enable_list_fix:
original = content
content = self._fix_list_formatting(content)
if content != original:
self.applied_fixes.append("Fix List Format")
# 6. Unclosed code block fix
if self.config.enable_unclosed_block_fix:
original = content
content = self._fix_unclosed_code_blocks(content)
if content != original:
self.applied_fixes.append("Close Code Blocks")
# 7. Full-width symbol fix (in code blocks only)
if self.config.enable_fullwidth_symbol_fix:
original = content
content = self._fix_fullwidth_symbols_in_code(content)
if content != original:
self.applied_fixes.append("Fix Full-width Symbols")
# 8. Mermaid syntax fix
if self.config.enable_mermaid_fix:
original = content
content = self._fix_mermaid_syntax(content)
if content != original:
self.applied_fixes.append("Fix Mermaid Syntax")
# 9. Heading fix
if self.config.enable_heading_fix:
original = content
content = self._fix_headings(content)
if content != original:
self.applied_fixes.append("Fix Headings")
# 10. Table fix
if self.config.enable_table_fix:
original = content
content = self._fix_tables(content)
if content != original:
self.applied_fixes.append("Fix Tables")
# 11. XML tag cleanup
if self.config.enable_xml_tag_cleanup:
original = content
content = self._cleanup_xml_tags(content)
if content != original:
self.applied_fixes.append("Cleanup XML Tags")
# 9. Custom cleaners
for cleaner in self.config.custom_cleaners:
original = content
content = cleaner(content)
if content != original:
self.applied_fixes.append("Custom Cleaner")
if self.applied_fixes:
logger.info(f"Markdown Normalizer Applied Fixes: {self.applied_fixes}")
logger.debug(
f"--- Original Content ---\n{original_content}\n------------------------"
)
logger.debug(
f"--- Normalized Content ---\n{content}\n--------------------------"
)
return content
except Exception as e:
# Production safeguard: return original content on error
logger.error(f"Content normalization failed: {e}", exc_info=True)
return content
def _fix_escape_characters(self, content: str) -> str:
"""Fix excessive escape characters"""
content = content.replace("\\r\\n", "\n")
content = content.replace("\\n", "\n")
content = content.replace("\\t", "\t")
content = content.replace("\\\\", "\\")
return content
def _fix_thought_tags(self, content: str) -> str:
"""Normalize thought tags: unify naming and fix spacing"""
# 1. Standardize start tag: <think>, <thinking> -> <thought>
content = self._PATTERNS["thought_start"].sub("<thought>", content)
# 2. Standardize end tag and ensure newlines: </think> -> </thought>\n\n
return self._PATTERNS["thought_end"].sub("</thought>\n\n", content)
def _fix_code_blocks(self, content: str) -> str:
"""Fix code block formatting (prefixes, suffixes, indentation)"""
# Remove indentation before code blocks
content = self._PATTERNS["code_block_indent"].sub(r"\1", content)
# Ensure newline before ```
content = self._PATTERNS["code_block_prefix"].sub(r"\n\1", content)
# Ensure newline after ```lang
content = self._PATTERNS["code_block_suffix"].sub(r"\1\n\2", content)
return content
def _fix_latex_formulas(self, content: str) -> str:
"""Normalize LaTeX formulas: \[ -> $$ (block), \( -> $ (inline)"""
content = self._PATTERNS["latex_bracket_block"].sub(r"$$\1$$", content)
content = self._PATTERNS["latex_paren_inline"].sub(r"$\1$", content)
return content
def _fix_list_formatting(self, content: str) -> str:
"""Fix missing newlines in lists (e.g., 'text1. item' -> 'text\\n1. item')"""
return self._PATTERNS["list_item"].sub(r"\1\n\2", content)
def _fix_unclosed_code_blocks(self, content: str) -> str:
"""Auto-close unclosed code blocks"""
if content.count("```") % 2 != 0:
content += "\n```"
return content
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
"""Convert full-width symbols to half-width inside code blocks"""
FULLWIDTH_MAP = {
"": ",",
"": ".",
"": "(",
"": ")",
"": "[",
"": "]",
"": ";",
"": ":",
"": "?",
"": "!",
'"': '"',
'"': '"',
""": "'", """: "'",
}
parts = content.split("```")
# Code block content is at odd indices: 1, 3, 5...
for i in range(1, len(parts), 2):
for full, half in FULLWIDTH_MAP.items():
parts[i] = parts[i].replace(full, half)
return "```".join(parts)
def _fix_mermaid_syntax(self, content: str) -> str:
"""Fix common Mermaid syntax errors while preserving node shapes"""
def replacer(match):
# Group 1 is Quoted String (if matched)
if match.group(1):
return match.group(1)
# Group 2 is ID
id_str = match.group(2)
# Find matching shape group
# Groups start at index 3 (in match.group terms) or index 2 (in match.groups() tuple)
# Tuple: (String, ID, Open1, Content1, Close1, ...)
groups = match.groups()
for i in range(2, len(groups), 3):
if groups[i] is not None:
open_char = groups[i]
content = groups[i + 1]
close_char = groups[i + 2]
# Escape quotes in content
content = content.replace('"', '\\"')
return f'{id_str}{open_char}"{content}"{close_char}'
return match.group(0)
parts = content.split("```")
for i in range(1, len(parts), 2):
# Check if it's a mermaid block
lang_line = parts[i].split("\n", 1)[0].strip().lower()
if "mermaid" in lang_line:
# Apply the comprehensive regex fix
parts[i] = self._PATTERNS["mermaid_node"].sub(replacer, parts[i])
# Auto-close subgraphs
subgraph_count = len(
re.findall(r"\bsubgraph\b", parts[i], re.IGNORECASE)
)
end_count = len(re.findall(r"\bend\b", parts[i], re.IGNORECASE))
if subgraph_count > end_count:
missing_ends = subgraph_count - end_count
parts[i] = parts[i].rstrip() + ("\n end" * missing_ends) + "\n"
return "```".join(parts)
def _fix_headings(self, content: str) -> str:
"""Fix missing space in headings: #Heading -> # Heading"""
# We only fix if it's not inside a code block.
# But splitting by code block is expensive.
# Given headings usually don't appear inside code blocks without space in valid code (except comments),
# we might risk false positives in comments like `#TODO`.
# To be safe, let's split by code blocks.
parts = content.split("```")
for i in range(0, len(parts), 2): # Even indices are markdown text
parts[i] = self._PATTERNS["heading_space"].sub(r"\1 \2", parts[i])
return "```".join(parts)
def _fix_tables(self, content: str) -> str:
"""Fix tables missing closing pipe"""
parts = content.split("```")
for i in range(0, len(parts), 2):
parts[i] = self._PATTERNS["table_pipe"].sub(r"\1|", parts[i])
return "```".join(parts)
def _cleanup_xml_tags(self, content: str) -> str:
"""Remove leftover XML tags"""
return self._PATTERNS["xml_artifacts"].sub("", content)
class Filter:
class Valves(BaseModel):
priority: int = Field(
default=50,
description="Priority level. Higher runs later (recommended to run after other filters).",
)
enable_escape_fix: bool = Field(
default=True, description="Fix excessive escape characters (\\n, \\t, etc.)"
)
enable_thought_tag_fix: bool = Field(
default=True, description="Normalize </thought> tags"
)
enable_code_block_fix: bool = Field(
default=True,
description="Fix code block formatting (indentation, newlines)",
)
enable_latex_fix: bool = Field(
default=True, description="Normalize LaTeX formulas (\\[ -> $$, \\( -> $)"
)
enable_list_fix: bool = Field(
default=False, description="Fix list item newlines (Experimental)"
)
enable_unclosed_block_fix: bool = Field(
default=True, description="Auto-close unclosed code blocks"
)
enable_fullwidth_symbol_fix: bool = Field(
default=False, description="Fix full-width symbols in code blocks"
)
enable_mermaid_fix: bool = Field(
default=True,
description="Fix common Mermaid syntax errors (e.g. unquoted labels)",
)
enable_heading_fix: bool = Field(
default=True,
description="Fix missing space in headings (#Header -> # Header)",
)
enable_table_fix: bool = Field(
default=True, description="Fix missing closing pipe in tables"
)
enable_xml_tag_cleanup: bool = Field(
default=True, description="Cleanup leftover XML tags"
)
show_status: bool = Field(
default=True, description="Show status notification when fixes are applied"
)
show_debug_log: bool = Field(
default=False, description="Print debug logs to browser console (F12)"
)
def __init__(self):
self.valves = self.Valves()
def _contains_html(self, content: str) -> bool:
"""Check if content contains HTML tags (to avoid breaking HTML output)"""
pattern = r"<\s*/?\s*(?:html|head|body|div|span|p|br|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|b|i|strong|em|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
return bool(re.search(pattern, content, re.IGNORECASE))
async def _emit_status(self, __event_emitter__, applied_fixes: List[str]):
"""Emit status notification"""
if not self.valves.show_status or not applied_fixes:
return
description = "✓ Markdown Normalized"
if applied_fixes:
description += f": {', '.join(applied_fixes)}"
try:
await __event_emitter__(
{
"type": "status",
"data": {
"description": description,
"done": True,
},
}
)
except Exception as e:
print(f"Error emitting status: {e}")
async def _emit_debug_log(
self, __event_call__, applied_fixes: List[str], original: str, normalized: str
):
"""Emit debug log to browser console via JS execution"""
if not self.valves.show_debug_log or not __event_call__:
return
try:
# Prepare data for JS
log_data = {
"fixes": applied_fixes,
"original": original,
"normalized": normalized,
}
# Construct JS code
js_code = f"""
(async function() {{
console.group("🛠️ Markdown Normalizer Debug");
console.log("Applied Fixes:", {json.dumps(applied_fixes, ensure_ascii=False)});
console.log("Original Content:", {json.dumps(original, ensure_ascii=False)});
console.log("Normalized Content:", {json.dumps(normalized, ensure_ascii=False)});
console.groupEnd();
}})();
"""
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
except Exception as e:
print(f"Error emitting debug log: {e}")
async def outlet(
self,
body: dict,
__user__: Optional[dict] = None,
__event_emitter__=None,
__event_call__=None,
__metadata__: Optional[dict] = None,
) -> dict:
"""
Process the response body to normalize Markdown content.
"""
if "messages" in body and body["messages"]:
last = body["messages"][-1]
content = last.get("content", "") or ""
if last.get("role") == "assistant" and isinstance(content, str):
# Skip if content looks like HTML to avoid breaking it
if self._contains_html(content):
return body
# Configure normalizer based on valves
config = NormalizerConfig(
enable_escape_fix=self.valves.enable_escape_fix,
enable_thought_tag_fix=self.valves.enable_thought_tag_fix,
enable_code_block_fix=self.valves.enable_code_block_fix,
enable_latex_fix=self.valves.enable_latex_fix,
enable_list_fix=self.valves.enable_list_fix,
enable_unclosed_block_fix=self.valves.enable_unclosed_block_fix,
enable_fullwidth_symbol_fix=self.valves.enable_fullwidth_symbol_fix,
enable_mermaid_fix=self.valves.enable_mermaid_fix,
enable_heading_fix=self.valves.enable_heading_fix,
enable_table_fix=self.valves.enable_table_fix,
enable_xml_tag_cleanup=self.valves.enable_xml_tag_cleanup,
)
normalizer = ContentNormalizer(config)
# Execute normalization
new_content = normalizer.normalize(content)
# Update content if changed
if new_content != content:
last["content"] = new_content
# Emit status if enabled
if __event_emitter__:
await self._emit_status(
__event_emitter__, normalizer.applied_fixes
)
await self._emit_debug_log(
__event_call__,
normalizer.applied_fixes,
content,
new_content,
)
return body

View File

@@ -0,0 +1,549 @@
"""
title: Markdown 格式修复器 (Markdown Normalizer)
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 1.0.0
description: 生产级内容规范化过滤器,修复 LLM 输出中常见的 Markdown 格式问题如损坏的代码块、LaTeX 公式、Mermaid 图表和列表格式。
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Callable
import re
import logging
import asyncio
import json
from dataclasses import dataclass, field
# Configure logging
logger = logging.getLogger(__name__)
@dataclass
class NormalizerConfig:
"""配置类,用于启用/禁用特定的规范化规则"""
enable_escape_fix: bool = True # 修复过度的转义字符
enable_thought_tag_fix: bool = True # 规范化思维链标签
enable_code_block_fix: bool = True # 修复代码块格式
enable_latex_fix: bool = True # 修复 LaTeX 公式格式
enable_list_fix: bool = False # 修复列表项换行 (默认关闭,因为可能过于激进)
enable_unclosed_block_fix: bool = True # 自动闭合未闭合的代码块
enable_fullwidth_symbol_fix: bool = False # 修复代码块中的全角符号
enable_mermaid_fix: bool = True # 修复常见的 Mermaid 语法错误
enable_heading_fix: bool = True # 修复标题中缺失的空格 (#Header -> # Header)
enable_table_fix: bool = True # 修复表格中缺失的闭合管道符
enable_xml_tag_cleanup: bool = True # 清理残留的 XML 标签
# 自定义清理函数 (用于高级扩展)
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
class ContentNormalizer:
"""LLM Output Content Normalizer - Production Grade Implementation"""
# --- 1. Pre-compiled Regex Patterns (Performance Optimization) ---
_PATTERNS = {
# Code block prefix: if ``` is not at start of line or file
"code_block_prefix": re.compile(r"(?<!^)(?<!\n)(```)", re.MULTILINE),
# Code block suffix: ```lang followed by non-whitespace (no newline)
"code_block_suffix": re.compile(r"(```[\w\+\-\.]*)[ \t]+([^\n\r])"),
# Code block indent: whitespace at start of line + ```
"code_block_indent": re.compile(r"^[ \t]+(```)", re.MULTILINE),
# Thought tag: </thought> followed by optional whitespace/newlines
"thought_end": re.compile(
r"</(thought|think|thinking)>[ \t]*\n*", re.IGNORECASE
),
"thought_start": re.compile(r"<(thought|think|thinking)>", re.IGNORECASE),
# LaTeX block: \[ ... \]
"latex_bracket_block": re.compile(r"\\\[(.+?)\\\]", re.DOTALL),
# LaTeX inline: \( ... \)
"latex_paren_inline": re.compile(r"\\\((.+?)\\\)"),
# List item: non-newline + digit + dot + space
"list_item": re.compile(r"([^\n])(\d+\. )"),
# XML artifacts (e.g. Claude's)
"xml_artifacts": re.compile(
r"</?(?:antArtifact|antThinking|artifact)[^>]*>", re.IGNORECASE
),
# Mermaid: 匹配各种形状的节点并为未加引号的标签添加引号
# 修复"反向优化"问题:必须精确匹配各种形状的定界符,避免破坏形状结构
# 优先级:长定界符优先匹配
"mermaid_node": re.compile(
r'("[^"\\]*(?:\\.[^"\\]*)*")|' # Match quoted strings first (Group 1)
r"(\w+)\s*(?:"
r"(\(\(\()(?![\"])(.*?)(?<![\"])(\)\)\))|" # (((...))) Double Circle
r"(\(\()(?![\"])(.*?)(?<![\"])(\)\))|" # ((...)) Circle
r"(\(\[)(?![\"])(.*?)(?<![\"])(\]\))|" # ([...]) Stadium
r"(\[\()(?![\"])(.*?)(?<![\"])(\)\])|" # [(...)] Cylinder
r"(\[\[)(?![\"])(.*?)(?<![\"])(\]\])|" # [[...]] Subroutine
r"(\{\{)(?![\"])(.*?)(?<![\"])(\}\})|" # {{...}} Hexagon
r"(\[/)(?![\"])(.*?)(?<![\"])(/\])|" # [/.../] Parallelogram
r"(\[\\)(?![\"])(.*?)(?<![\"])(\\\])|" # [\...\] Parallelogram Alt
r"(\[/)(?![\"])(.*?)(?<![\"])(\\\])|" # [/...\] Trapezoid
r"(\[\\)(?![\"])(.*?)(?<![\"])(/\])|" # [\.../] Trapezoid Alt
r"(\()(?![\"])(.*?)(?<![\"])(\))|" # (...) Round
r"(\[)(?![\"])(.*?)(?<![\"])(\])|" # [...] Square
r"(\{)(?![\"])(.*?)(?<![\"])(\})|" # {...} Rhombus
r"(>)(?![\"])(.*?)(?<![\"])(\])" # >...] Asymmetric
r")"
),
# Heading: #Heading -> # Heading
"heading_space": re.compile(r"^(#+)([^ \n#])", re.MULTILINE),
# Table: | col1 | col2 -> | col1 | col2 |
"table_pipe": re.compile(r"^(\|.*[^|\n])$", re.MULTILINE),
}
def __init__(self, config: Optional[NormalizerConfig] = None):
self.config = config or NormalizerConfig()
self.applied_fixes = []
def normalize(self, content: str) -> str:
"""Main entry point: apply all normalization rules in order"""
self.applied_fixes = []
if not content:
return content
original_content = content # Keep a copy for logging
try:
# 1. Escape character fix (Must be first)
if self.config.enable_escape_fix:
original = content
content = self._fix_escape_characters(content)
if content != original:
self.applied_fixes.append("Fix Escape Chars")
# 2. Thought tag normalization
if self.config.enable_thought_tag_fix:
original = content
content = self._fix_thought_tags(content)
if content != original:
self.applied_fixes.append("Normalize Thought Tags")
# 3. Code block formatting fix
if self.config.enable_code_block_fix:
original = content
content = self._fix_code_blocks(content)
if content != original:
self.applied_fixes.append("Fix Code Blocks")
# 4. LaTeX formula normalization
if self.config.enable_latex_fix:
original = content
content = self._fix_latex_formulas(content)
if content != original:
self.applied_fixes.append("Normalize LaTeX")
# 5. List formatting fix
if self.config.enable_list_fix:
original = content
content = self._fix_list_formatting(content)
if content != original:
self.applied_fixes.append("Fix List Format")
# 6. Unclosed code block fix
if self.config.enable_unclosed_block_fix:
original = content
content = self._fix_unclosed_code_blocks(content)
if content != original:
self.applied_fixes.append("Close Code Blocks")
# 7. Full-width symbol fix (in code blocks only)
if self.config.enable_fullwidth_symbol_fix:
original = content
content = self._fix_fullwidth_symbols_in_code(content)
if content != original:
self.applied_fixes.append("Fix Full-width Symbols")
# 8. Mermaid syntax fix
if self.config.enable_mermaid_fix:
original = content
content = self._fix_mermaid_syntax(content)
if content != original:
self.applied_fixes.append("Fix Mermaid Syntax")
# 9. Heading fix
if self.config.enable_heading_fix:
original = content
content = self._fix_headings(content)
if content != original:
self.applied_fixes.append("Fix Headings")
# 10. Table fix
if self.config.enable_table_fix:
original = content
content = self._fix_tables(content)
if content != original:
self.applied_fixes.append("Fix Tables")
# 11. XML tag cleanup
if self.config.enable_xml_tag_cleanup:
original = content
content = self._cleanup_xml_tags(content)
if content != original:
self.applied_fixes.append("Cleanup XML Tags")
# 9. Custom cleaners
for cleaner in self.config.custom_cleaners:
original = content
content = cleaner(content)
if content != original:
self.applied_fixes.append("Custom Cleaner")
if self.applied_fixes:
print(f"[Markdown Normalizer] Applied fixes: {self.applied_fixes}")
print(
f"[Markdown Normalizer] --- Original Content ---\n{original_content}\n------------------------"
)
print(
f"[Markdown Normalizer] --- Normalized Content ---\n{content}\n--------------------------"
)
return content
except Exception as e:
# Production safeguard: return original content on error
logger.error(f"Content normalization failed: {e}", exc_info=True)
return content
def _fix_escape_characters(self, content: str) -> str:
"""Fix excessive escape characters"""
content = content.replace("\\r\\n", "\n")
content = content.replace("\\n", "\n")
content = content.replace("\\t", "\t")
content = content.replace("\\\\", "\\")
return content
def _fix_thought_tags(self, content: str) -> str:
"""Normalize thought tags: unify naming and fix spacing"""
# 1. Standardize start tag: <think>, <thinking> -> <thought>
content = self._PATTERNS["thought_start"].sub("<thought>", content)
# 2. Standardize end tag and ensure newlines: </think> -> </thought>\n\n
return self._PATTERNS["thought_end"].sub("</thought>\n\n", content)
def _fix_code_blocks(self, content: str) -> str:
"""Fix code block formatting (prefixes, suffixes, indentation)"""
# Remove indentation before code blocks
content = self._PATTERNS["code_block_indent"].sub(r"\1", content)
# Ensure newline before ```
content = self._PATTERNS["code_block_prefix"].sub(r"\n\1", content)
# Ensure newline after ```lang
content = self._PATTERNS["code_block_suffix"].sub(r"\1\n\2", content)
return content
def _fix_latex_formulas(self, content: str) -> str:
"""Normalize LaTeX formulas: \[ -> $$ (block), \( -> $ (inline)"""
content = self._PATTERNS["latex_bracket_block"].sub(r"$$\1$$", content)
content = self._PATTERNS["latex_paren_inline"].sub(r"$\1$", content)
return content
def _fix_list_formatting(self, content: str) -> str:
"""Fix missing newlines in lists (e.g., 'text1. item' -> 'text\\n1. item')"""
return self._PATTERNS["list_item"].sub(r"\1\n\2", content)
def _fix_unclosed_code_blocks(self, content: str) -> str:
"""Auto-close unclosed code blocks"""
if content.count("```") % 2 != 0:
content += "\n```"
return content
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
"""Convert full-width symbols to half-width inside code blocks"""
FULLWIDTH_MAP = {
"": ",",
"": ".",
"": "(",
"": ")",
"": "[",
"": "]",
"": ";",
"": ":",
"": "?",
"": "!",
'"': '"',
'"': '"',
""": "'", """: "'",
}
parts = content.split("```")
# Code block content is at odd indices: 1, 3, 5...
for i in range(1, len(parts), 2):
for full, half in FULLWIDTH_MAP.items():
parts[i] = parts[i].replace(full, half)
return "```".join(parts)
def _fix_mermaid_syntax(self, content: str) -> str:
"""修复常见的 Mermaid 语法错误,同时保留节点形状"""
def replacer(match):
# Group 1 is Quoted String (if matched)
if match.group(1):
return match.group(1)
# Group 2 is ID
id_str = match.group(2)
# Find matching shape group
# Groups start at index 3 (in match.group terms) or index 2 (in match.groups() tuple)
# Tuple: (String, ID, Open1, Content1, Close1, ...)
groups = match.groups()
for i in range(2, len(groups), 3):
if groups[i] is not None:
open_char = groups[i]
content = groups[i + 1]
close_char = groups[i + 2]
# 如果内容包含引号,进行转义
content = content.replace('"', '\\"')
return f'{id_str}{open_char}"{content}"{close_char}'
return match.group(0)
parts = content.split("```")
for i in range(1, len(parts), 2):
# Check if it's a mermaid block
lang_line = parts[i].split("\n", 1)[0].strip().lower()
if "mermaid" in lang_line:
# Apply the comprehensive regex fix
parts[i] = self._PATTERNS["mermaid_node"].sub(replacer, parts[i])
# Auto-close subgraphs
# Count 'subgraph' and 'end' (case-insensitive)
# We use a simple regex to avoid matching words inside labels (though labels are now quoted, so it's safer)
# But for simplicity and speed, we just count occurrences in the whole block.
# A more robust way would be to strip quoted strings first, but that's expensive.
# Given we just quoted everything, let's try to count keywords outside quotes?
# Actually, since we just normalized nodes, most text is in quotes.
# Let's just do a simple count. It's a heuristic fix.
subgraph_count = len(
re.findall(r"\bsubgraph\b", parts[i], re.IGNORECASE)
)
end_count = len(re.findall(r"\bend\b", parts[i], re.IGNORECASE))
if subgraph_count > end_count:
missing_ends = subgraph_count - end_count
parts[i] = parts[i].rstrip() + ("\n end" * missing_ends) + "\n"
return "```".join(parts)
def _fix_headings(self, content: str) -> str:
"""Fix missing space in headings: #Heading -> # Heading"""
# We only fix if it's not inside a code block.
# But splitting by code block is expensive.
# Given headings usually don't appear inside code blocks without space in valid code (except comments),
# we might risk false positives in comments like `#TODO`.
# To be safe, let's split by code blocks.
parts = content.split("```")
for i in range(0, len(parts), 2): # Even indices are markdown text
parts[i] = self._PATTERNS["heading_space"].sub(r"\1 \2", parts[i])
return "```".join(parts)
def _fix_tables(self, content: str) -> str:
"""Fix tables missing closing pipe"""
parts = content.split("```")
for i in range(0, len(parts), 2):
parts[i] = self._PATTERNS["table_pipe"].sub(r"\1|", parts[i])
return "```".join(parts)
def _cleanup_xml_tags(self, content: str) -> str:
"""Remove leftover XML tags"""
return self._PATTERNS["xml_artifacts"].sub("", content)
class Filter:
class Valves(BaseModel):
priority: int = Field(
default=50,
description="优先级。数值越高运行越晚 (建议在其他过滤器之后运行)。",
)
enable_escape_fix: bool = Field(
default=True, description="修复过度的转义字符 (\\n, \\t 等)"
)
enable_thought_tag_fix: bool = Field(
default=True, description="规范化思维链标签 (<think> -> <thought>)"
)
enable_code_block_fix: bool = Field(
default=True,
description="修复代码块格式 (缩进、换行)",
)
enable_latex_fix: bool = Field(
default=True, description="规范化 LaTeX 公式 (\\[ -> $$, \\( -> $)"
)
enable_list_fix: bool = Field(
default=False, description="修复列表项换行 (实验性)"
)
enable_unclosed_block_fix: bool = Field(
default=True, description="自动闭合未闭合的代码块"
)
enable_fullwidth_symbol_fix: bool = Field(
default=False, description="修复代码块中的全角符号"
)
enable_mermaid_fix: bool = Field(
default=True,
description="修复常见的 Mermaid 语法错误 (如未加引号的标签)",
)
enable_heading_fix: bool = Field(
default=True,
description="修复标题中缺失的空格 (#Header -> # Header)",
)
enable_table_fix: bool = Field(
default=True, description="修复表格中缺失的闭合管道符"
)
enable_xml_tag_cleanup: bool = Field(
default=True, description="清理残留的 XML 标签"
)
show_status: bool = Field(default=True, description="应用修复时显示状态通知")
show_debug_log: bool = Field(
default=False, description="在浏览器控制台打印调试日志 (F12)"
)
def __init__(self):
self.valves = self.Valves()
def _contains_html(self, content: str) -> bool:
"""Check if content contains HTML tags (to avoid breaking HTML output)"""
pattern = r"<\s*/?\s*(?:html|head|body|div|span|p|br|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|b|i|strong|em|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
return bool(re.search(pattern, content, re.IGNORECASE))
async def _emit_status(self, __event_emitter__, applied_fixes: List[str]):
"""Emit status notification"""
if not self.valves.show_status or not applied_fixes:
return
description = "✓ Markdown 已修复"
if applied_fixes:
# Translate fix names for status display
fix_map = {
"Fix Escape Chars": "转义字符",
"Normalize Thought Tags": "思维标签",
"Fix Code Blocks": "代码块",
"Normalize LaTeX": "LaTeX公式",
"Fix List Format": "列表格式",
"Close Code Blocks": "闭合代码块",
"Fix Full-width Symbols": "全角符号",
"Fix Mermaid Syntax": "Mermaid语法",
"Fix Headings": "标题格式",
"Fix Tables": "表格格式",
"Cleanup XML Tags": "XML清理",
"Custom Cleaner": "自定义清理",
}
translated_fixes = [fix_map.get(fix, fix) for fix in applied_fixes]
description += f": {', '.join(translated_fixes)}"
try:
await __event_emitter__(
{
"type": "status",
"data": {
"description": description,
"done": True,
},
}
)
except Exception as e:
print(f"Error emitting status: {e}")
async def _emit_debug_log(
self,
__event_emitter__,
applied_fixes: List[str],
original: str,
normalized: str,
):
"""Emit debug log to browser console via JS execution"""
async def _emit_debug_log(
self, __event_call__, applied_fixes: List[str], original: str, normalized: str
):
"""Emit debug log to browser console via JS execution"""
if not self.valves.show_debug_log or not __event_call__:
return
try:
# Prepare data for JS
log_data = {
"fixes": applied_fixes,
"original": original,
"normalized": normalized,
}
# Construct JS code
js_code = f"""
(async function() {{
console.group("🛠️ Markdown Normalizer Debug");
console.log("Applied Fixes:", {json.dumps(applied_fixes, ensure_ascii=False)});
console.log("Original Content:", {json.dumps(original, ensure_ascii=False)});
console.log("Normalized Content:", {json.dumps(normalized, ensure_ascii=False)});
console.groupEnd();
}})();
"""
await __event_call__(
{
"type": "execute",
"data": {"code": js_code},
}
)
except Exception as e:
print(f"Error emitting debug log: {e}")
async def outlet(
self,
body: dict,
__user__: Optional[dict] = None,
__event_emitter__=None,
__event_call__=None,
__metadata__: Optional[dict] = None,
) -> dict:
"""
Process the response body to normalize Markdown content.
"""
if "messages" in body and body["messages"]:
last = body["messages"][-1]
content = last.get("content", "") or ""
if last.get("role") == "assistant" and isinstance(content, str):
# Skip if content looks like HTML to avoid breaking it
if self._contains_html(content):
return body
# Configure normalizer based on valves
config = NormalizerConfig(
enable_escape_fix=self.valves.enable_escape_fix,
enable_thought_tag_fix=self.valves.enable_thought_tag_fix,
enable_code_block_fix=self.valves.enable_code_block_fix,
enable_latex_fix=self.valves.enable_latex_fix,
enable_list_fix=self.valves.enable_list_fix,
enable_unclosed_block_fix=self.valves.enable_unclosed_block_fix,
enable_fullwidth_symbol_fix=self.valves.enable_fullwidth_symbol_fix,
enable_mermaid_fix=self.valves.enable_mermaid_fix,
enable_heading_fix=self.valves.enable_heading_fix,
enable_table_fix=self.valves.enable_table_fix,
enable_xml_tag_cleanup=self.valves.enable_xml_tag_cleanup,
)
normalizer = ContentNormalizer(config)
# Execute normalization
new_content = normalizer.normalize(content)
# Update content if changed
if new_content != content:
last["content"] = new_content
# Emit status if enabled
if __event_emitter__:
await self._emit_status(
__event_emitter__, normalizer.applied_fixes
)
await self._emit_debug_log(
__event_call__,
normalizer.applied_fixes,
content,
new_content,
)
return body

View File

@@ -0,0 +1,191 @@
import unittest
import sys
import os
# Add the current directory to sys.path to import the module
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from markdown_normalizer import ContentNormalizer, NormalizerConfig
class TestMarkdownNormalizer(unittest.TestCase):
def setUp(self):
self.config = NormalizerConfig(
enable_escape_fix=True,
enable_thought_tag_fix=True,
enable_code_block_fix=True,
enable_latex_fix=True,
enable_list_fix=True,
enable_unclosed_block_fix=True,
enable_fullwidth_symbol_fix=True,
enable_mermaid_fix=True,
enable_xml_tag_cleanup=True,
)
self.normalizer = ContentNormalizer(self.config)
def test_escape_fix(self):
input_text = "Line 1\\nLine 2\\tTabbed"
expected = "Line 1\nLine 2\tTabbed"
self.assertEqual(self.normalizer.normalize(input_text), expected)
def test_thought_tag_fix(self):
# Case 1: Standard tag spacing
input_text = "Thinking...</thought>Result"
expected = "Thinking...</thought>\n\nResult"
self.assertEqual(self.normalizer.normalize(input_text), expected)
# Case 2: Tag standardization (<think> -> <thought>)
input_text_deepseek = "<think>Deep thinking...</think>Result"
expected_deepseek = "<thought>Deep thinking...</thought>\n\nResult"
self.assertEqual(
self.normalizer.normalize(input_text_deepseek), expected_deepseek
)
def test_code_block_fix(self):
# Case 1: Indentation
self.assertEqual(self.normalizer._fix_code_blocks(" ```python"), "```python")
# Case 2: Prefix (newline before block)
self.assertEqual(
self.normalizer._fix_code_blocks("Text```python"), "Text\n```python"
)
# Case 3: Suffix (newline after lang)
self.assertEqual(
self.normalizer._fix_code_blocks("```python print('hi')"),
"```python\nprint('hi')",
)
def test_latex_fix(self):
input_text = "Block: \\[ x^2 \\] Inline: \\( E=mc^2 \\)"
expected = "Block: $$ x^2 $$ Inline: $ E=mc^2 $"
self.assertEqual(self.normalizer.normalize(input_text), expected)
def test_list_fix(self):
input_text = "Item 1. First\nItem 2. Second" # This is fine
input_text_bad = "Header1. Item 1"
expected = "Header\n1. Item 1"
self.assertEqual(self.normalizer.normalize(input_text_bad), expected)
def test_unclosed_code_block_fix(self):
input_text = "```python\nprint('hello')"
expected = "```python\nprint('hello')\n```"
self.assertEqual(self.normalizer.normalize(input_text), expected)
def test_fullwidth_symbol_fix(self):
input_text = "OutsideFullwidth ```python\nprint'hello'```"
expected = "OutsideFullwidth \n```python\nprint('hello')\n```"
normalized = self.normalizer.normalize(input_text)
self.assertIn("print('hello')", normalized)
self.assertIn("OutsideFullwidth", normalized)
self.assertNotIn("", normalized)
self.assertNotIn("", normalized)
def test_mermaid_fix(self):
# Test Mermaid syntax fix for unquoted labels
# Note: Regex-based fix handles mixed brackets well (e.g. [] inside ())
# but cannot perfectly handle same-type nesting (e.g. {} inside {}) without a parser.
input_text = """
```mermaid
graph TD
A[Label with (parens)] --> B(Label with [brackets])
C{Label with [brackets]}
```
"""
expected_snippet = """
```mermaid
graph TD
A["Label with (parens)"] --> B("Label with [brackets]")
C{"Label with [brackets]"}
```
"""
normalized = self.normalizer.normalize(input_text)
self.assertIn('A["Label with (parens)"]', normalized)
self.assertIn('B("Label with [brackets]")', normalized)
self.assertIn('C{"Label with [brackets]"}', normalized)
def test_mermaid_shapes_regression(self):
# Regression test for "reverse optimization" where ((...)) was broken into ("(...)")
input_text = """
```mermaid
graph TD
Start((开始)) --> Input[[输入]]
Input --> Verify{验证}
Verify --> End(((结束)))
```
"""
expected_snippet = """
```mermaid
graph TD
Start(("开始")) --> Input[["输入"]]
Input --> Verify{"验证"}
Verify --> End((("结束")))
```
"""
normalized = self.normalizer.normalize(input_text)
self.assertIn('Start(("开始"))', normalized)
self.assertIn('Input[["输入"]]', normalized)
self.assertIn('Verify{"验证"}', normalized)
self.assertIn('End((("结束")))', normalized)
def test_xml_cleanup(self):
input_text = "Some text <antArtifact>hidden</antArtifact> visible"
expected = "Some text hidden visible"
self.assertEqual(self.normalizer.normalize(input_text), expected)
def test_heading_fix(self):
input_text = "#Heading 1\n##Heading 2\n### Valid Heading"
expected = "# Heading 1\n## Heading 2\n### Valid Heading"
self.assertEqual(self.normalizer.normalize(input_text), expected)
def test_table_fix(self):
input_text = "| Col 1 | Col 2\n| Val 1 | Val 2"
expected = "| Col 1 | Col 2|\n| Val 1 | Val 2|"
self.assertEqual(self.normalizer.normalize(input_text), expected)
def test_mermaid_subgraph_autoclose(self):
"""Test auto-closing of Mermaid subgraphs"""
# Case 1: Simple unclosed subgraph
original = """
```mermaid
graph TD
subgraph One
A --> B
```
"""
expected = """
```mermaid
graph TD
subgraph One
A --> B
end
```
"""
# Note: The normalizer might add quotes to A and B if they match the node pattern,
# but here they are simple IDs. However, our regex is strict about shapes.
# Simple IDs like A and B are NOT matched by our mermaid_node regex because it requires a shape delimiter.
# So A and B remain A and B.
normalized = self.normalizer.normalize(original)
# We need to be careful about whitespace in comparison
self.assertIn("end", normalized)
self.assertEqual(normalized.strip(), expected.strip())
# Case 2: Nested unclosed subgraphs
original_nested = """
```mermaid
graph TD
subgraph Outer
subgraph Inner
C --> D
```
"""
normalized_nested = self.normalizer.normalize(original_nested)
self.assertEqual(normalized_nested.count("end"), 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,133 @@
"""
Download plugin images from OpenWebUI Community
下载远程插件图片到本地目录
"""
import os
import sys
import re
import requests
from urllib.parse import urlparse
# Add current directory to path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from openwebui_community_client import get_client
def find_local_plugin_by_id(plugins_dir: str, post_id: str) -> str | None:
"""根据 post_id 查找本地插件文件"""
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)
id_match = re.search(
r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content
)
if id_match and id_match.group(1).strip() == post_id:
return file_path
return None
def download_image(url: str, save_path: str) -> bool:
"""下载图片"""
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
with open(save_path, "wb") as f:
f.write(response.content)
return True
except Exception as e:
print(f" Error downloading: {e}")
return False
def get_image_extension(url: str) -> str:
"""从 URL 获取图片扩展名"""
parsed = urlparse(url)
path = parsed.path
ext = os.path.splitext(path)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".gif", ".webp"]:
return ext
return ".png" # 默认
def main():
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")
print("Fetching remote posts from OpenWebUI Community...")
posts = client.get_all_posts()
print(f"Found {len(posts)} remote posts.\n")
downloaded = 0
skipped = 0
not_found = 0
for post in posts:
post_id = post.get("id")
title = post.get("title", "Unknown")
media = post.get("media", [])
if not media:
continue
# 只取第一张图片
first_media = media[0] if isinstance(media, list) else media
# 处理字典格式 {'url': '...', 'type': 'image'}
if isinstance(first_media, dict):
image_url = first_media.get("url")
else:
image_url = first_media
if not image_url:
continue
print(f"Processing: {title}")
print(f" Image URL: {image_url}")
# 查找对应的本地插件
local_plugin = find_local_plugin_by_id(plugins_dir, post_id)
if not local_plugin:
print(f" ⚠️ No local plugin found for ID: {post_id}")
not_found += 1
continue
# 确定保存路径
plugin_dir = os.path.dirname(local_plugin)
plugin_name = os.path.splitext(os.path.basename(local_plugin))[0]
ext = get_image_extension(image_url)
save_path = os.path.join(plugin_dir, plugin_name + ext)
# 检查是否已存在
if os.path.exists(save_path):
print(f" ⏭️ Image already exists: {os.path.basename(save_path)}")
skipped += 1
continue
# 下载
print(f" Downloading to: {save_path}")
if download_image(image_url, save_path):
print(f" ✅ Downloaded: {os.path.basename(save_path)}")
downloaded += 1
else:
print(f" ❌ Failed to download")
print(f"\n{'='*50}")
print(
f"Finished: {downloaded} downloaded, {skipped} skipped, {not_found} not found locally"
)
if __name__ == "__main__":
main()

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,659 @@
"""
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",
}
# 如果没有 user_id尝试通过 API 获取
if not self.user_id:
self.user_id = self._get_user_id_from_api()
def _parse_user_id_from_token(self, token: str) -> Optional[str]:
"""从 JWT Token 中解析用户 ID"""
# sk- 开头的是 API Key无法解析用户 ID
if token.startswith("sk-"):
return None
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_id_from_api(self) -> Optional[str]:
"""通过 API 获取当前用户 ID"""
try:
url = f"{self.BASE_URL}/auths/"
response = requests.get(url, headers=self.headers)
response.raise_for_status()
data = response.json()
return data.get("id")
except Exception:
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/users/{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 create_post(
self,
title: str,
content: str,
post_type: str = "function",
data: Optional[Dict] = None,
media: Optional[List[str]] = None,
) -> Optional[Dict]:
"""
创建新帖子
Args:
title: 帖子标题
content: 帖子内容README/描述)
post_type: 帖子类型 (function/tool/filter/pipeline)
data: 插件数据结构
media: 图片 URL 列表
Returns:
创建成功返回帖子数据,失败返回 None
"""
try:
url = f"{self.BASE_URL}/posts/create"
# 将字符串 URL 转换为字典格式 (API 要求)
media_list = []
if media:
for item in media:
if isinstance(item, str):
media_list.append({"url": item})
elif isinstance(item, dict):
media_list.append(item)
payload = {
"title": title,
"content": content,
"type": post_type,
"data": data or {},
"media": media_list,
}
print(f" [DEBUG] Payload keys: {list(payload.keys())}")
print(
f" [DEBUG] media format: {media_list[:1] if media_list else 'empty'}"
)
response = requests.post(url, headers=self.headers, json=payload)
if response.status_code != 200:
print(f" [DEBUG] Response status: {response.status_code}")
print(f" [DEBUG] Response body: {response.text[:500]}")
response.raise_for_status()
return response.json()
except Exception as e:
print(f" Error creating post: {e}")
return None
def create_plugin(
self,
title: str,
source_code: str,
readme_content: Optional[str] = None,
metadata: Optional[Dict] = None,
media_urls: Optional[List[str]] = None,
plugin_type: str = "action",
) -> Optional[str]:
"""
创建新插件帖子
Args:
title: 插件标题
source_code: 插件源代码
readme_content: README 内容
metadata: 插件元数据
media_urls: 图片 URL 列表
plugin_type: 插件类型 (action/filter/pipe)
Returns:
创建成功返回帖子 ID失败返回 None
"""
# 构建 function 数据结构
function_data = {
"id": "", # 服务器会生成
"name": title,
"type": plugin_type,
"content": source_code,
"meta": {
"description": metadata.get("description", "") if metadata else "",
"manifest": metadata or {},
},
}
data = {"function": function_data}
result = self.create_post(
title=title,
content=(
readme_content or metadata.get("description", "") if metadata else ""
),
post_type="function",
data=data,
media=media_urls,
)
if result:
return result.get("id")
return None
# ========== 帖子/插件更新 ==========
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,
media_urls: Optional[List[str]] = None,
) -> bool:
"""
更新插件(代码 + README + 元数据 + 图片)
Args:
post_id: 帖子 ID
source_code: 插件源代码
readme_content: README 内容(用于社区页面展示)
metadata: 插件元数据title, version, description 等)
media_urls: 图片 URL 列表
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"
]
# 更新图片
if media_urls:
post_data["media"] = media_urls
return self.update_post(post_id, post_data)
# ========== 图片上传 ==========
def upload_image(self, file_path: str) -> Optional[str]:
"""
上传图片到 OpenWebUI 社区
Args:
file_path: 图片文件路径
Returns:
上传成功后的图片 URL失败返回 None
"""
if not os.path.exists(file_path):
return None
# 获取文件信息
filename = os.path.basename(file_path)
# 根据文件扩展名确定 MIME 类型
ext = os.path.splitext(filename)[1].lower()
mime_types = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
content_type = mime_types.get(ext, "application/octet-stream")
try:
with open(file_path, "rb") as f:
files = {"file": (filename, f, content_type)}
# 上传时不使用 JSON Content-Type
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
}
response = requests.post(
f"{self.BASE_URL}/files/",
headers=headers,
files=files,
)
response.raise_for_status()
result = response.json()
# 返回图片 URL
return result.get("url")
except Exception as e:
print(f" Warning: Failed to upload image: {e}")
return None
# ========== 版本比较 ==========
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, auto_create: bool = True
) -> Tuple[bool, str]:
"""
从文件发布插件(支持首次创建和更新)
Args:
file_path: 插件文件路径
force: 是否强制更新(忽略版本检查)
auto_create: 如果没有 openwebui_id是否自动创建新帖子
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"
title = metadata.get("title")
if not title:
return False, "No title in frontmatter"
post_id = metadata.get("openwebui_id") or metadata.get("post_id")
local_version = metadata.get("version")
# 查找 README
readme_content = self._find_readme(file_path)
# 查找并上传图片
media_urls = None
image_path = self._find_image(file_path)
if image_path:
print(f" Found image: {os.path.basename(image_path)}")
image_url = self.upload_image(image_path)
if image_url:
print(f" Uploaded image: {image_url}")
media_urls = [image_url]
# 如果没有 post_id尝试创建新帖子
if not post_id:
if not auto_create:
return False, "No openwebui_id found and auto_create is disabled"
print(f" Creating new post for: {title}")
new_post_id = self.create_plugin(
title=title,
source_code=content,
readme_content=readme_content or metadata.get("description", ""),
metadata=metadata,
media_urls=media_urls,
)
if new_post_id:
# 将新 ID 写回本地文件
self._inject_id_to_file(file_path, new_post_id)
return True, f"Created new post (ID: {new_post_id})"
return False, "Failed to create new post"
# 版本检查(仅对更新有效)
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"
# 更新
success = self.update_plugin(
post_id=post_id,
source_code=content,
readme_content=readme_content or metadata.get("description", ""),
metadata=metadata,
media_urls=media_urls,
)
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 _find_image(self, plugin_file_path: str) -> Optional[str]:
"""
查找插件对应的图片文件
图片名称需要和插件文件名一致(不含扩展名)
例如:
export_to_word.py -> export_to_word.png / export_to_word.jpg
"""
plugin_dir = os.path.dirname(plugin_file_path)
plugin_name = os.path.splitext(os.path.basename(plugin_file_path))[0]
# 支持的图片格式
image_extensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"]
for ext in image_extensions:
image_path = os.path.join(plugin_dir, plugin_name + ext)
if os.path.exists(image_path):
return image_path
return None
def _inject_id_to_file(self, file_path: str, post_id: str) -> bool:
"""
将新创建的帖子 ID 写回本地插件文件的 frontmatter
Args:
file_path: 插件文件路径
post_id: 新创建的帖子 ID
Returns:
是否成功
"""
try:
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:
in_frontmatter = False
new_lines.append(line)
# Insert after version line
if (
in_frontmatter
and not inserted
and line.strip().startswith("version:")
):
new_lines.append(f"openwebui_id: {post_id}\n")
inserted = True
print(f" Injected openwebui_id: {post_id}")
if inserted:
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(new_lines)
return True
print(f" Warning: Could not inject ID (no version line found)")
return False
except Exception as e:
print(f" Error injecting ID to file: {e}")
return False
# ========== 统计功能 ==========
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)

618
scripts/openwebui_stats.py Normal file
View File

@@ -0,0 +1,618 @@
#!/usr/bin/env python3
"""
OpenWebUI 社区统计工具
获取并统计你在 openwebui.com 上发布的插件/帖子数据。
使用方法:
1. 设置环境变量:
- OPENWEBUI_API_KEY: 你的 API Key
- OPENWEBUI_USER_ID: 你的用户 ID
2. 运行: python scripts/openwebui_stats.py
获取 API Key
访问 https://openwebui.com/settings/api 创建 API Key (sk-开头)
获取 User ID
从个人主页的 API 请求中获取,格式如: b15d1348-4347-42b4-b815-e053342d6cb0
"""
import os
import json
import requests
from datetime import datetime, timezone, timedelta
from typing import Optional
from pathlib import Path
# 北京时区 (UTC+8)
BEIJING_TZ = timezone(timedelta(hours=8))
def get_beijing_time() -> datetime:
"""获取当前北京时间"""
return datetime.now(BEIJING_TZ)
# 尝试加载 .env 文件
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
class OpenWebUIStats:
"""OpenWebUI 社区统计工具"""
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.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
}
)
def _parse_user_id_from_token(self, token: str) -> str:
"""从 JWT Token 中解析用户 ID"""
import base64
try:
# JWT 格式: header.payload.signature
payload = token.split(".")[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", "")
except Exception as e:
print(f"⚠️ 无法从 Token 解析用户 ID: {e}")
return ""
def get_user_posts(self, sort: str = "new", page: int = 1) -> list:
"""
获取用户发布的帖子列表
Args:
sort: 排序方式 (new/top/hot)
page: 页码
Returns:
帖子列表
"""
url = f"{self.BASE_URL}/posts/users/{self.user_id}"
params = {"sort": sort, "page": page}
response = self.session.get(url, params=params)
response.raise_for_status()
return response.json()
def get_all_posts(self, sort: str = "new") -> list:
"""获取所有帖子(自动分页)"""
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 generate_stats(self, posts: list) -> dict:
"""生成统计数据"""
stats = {
"total_posts": len(posts),
"total_downloads": 0,
"total_views": 0,
"total_upvotes": 0,
"total_downvotes": 0,
"total_saves": 0,
"total_comments": 0,
"by_type": {},
"posts": [],
"user": {}, # 用户信息
}
# 从第一个帖子中提取用户信息
if posts and "user" in posts[0]:
user = posts[0]["user"]
stats["user"] = {
"username": user.get("username", ""),
"name": user.get("name", ""),
"profile_url": f"https://openwebui.com/u/{user.get('username', '')}",
"profile_image": user.get("profileImageUrl", ""),
"followers": user.get("followerCount", 0),
"following": user.get("followingCount", 0),
"total_points": user.get("totalPoints", 0),
"post_points": user.get("postPoints", 0),
"comment_points": user.get("commentPoints", 0),
"contributions": user.get("totalContributions", 0),
}
for post in posts:
# 累计统计
stats["total_downloads"] += post.get("downloads", 0)
stats["total_views"] += post.get("views", 0)
stats["total_upvotes"] += post.get("upvotes", 0)
stats["total_downvotes"] += post.get("downvotes", 0)
stats["total_saves"] += post.get("saveCount", 0)
stats["total_comments"] += post.get("commentCount", 0)
# 解析 data 字段 - 正确路径: data.function.meta
function_data = post.get("data", {})
if function_data is None:
function_data = {}
function_data = function_data.get("function", {})
meta = function_data.get("meta", {})
manifest = meta.get("manifest", {})
post_type = meta.get("type", function_data.get("type", "unknown"))
if post_type not in stats["by_type"]:
stats["by_type"][post_type] = 0
stats["by_type"][post_type] += 1
# 单个帖子信息
created_at = datetime.fromtimestamp(post.get("createdAt", 0))
updated_at = datetime.fromtimestamp(post.get("updatedAt", 0))
stats["posts"].append(
{
"title": post.get("title", ""),
"slug": post.get("slug", ""),
"type": post_type,
"version": manifest.get("version", ""),
"author": manifest.get("author", ""),
"description": meta.get("description", ""),
"downloads": post.get("downloads", 0),
"views": post.get("views", 0),
"upvotes": post.get("upvotes", 0),
"saves": post.get("saveCount", 0),
"comments": post.get("commentCount", 0),
"created_at": created_at.strftime("%Y-%m-%d"),
"updated_at": updated_at.strftime("%Y-%m-%d"),
"url": f"https://openwebui.com/posts/{post.get('slug', '')}",
}
)
# 按下载量排序
stats["posts"].sort(key=lambda x: x["downloads"], reverse=True)
return stats
def print_stats(self, stats: dict):
"""打印统计报告到终端"""
print("\n" + "=" * 60)
print("📊 OpenWebUI 社区统计报告")
print("=" * 60)
print(f"📅 生成时间 (北京): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}")
print()
# 总览
print("📈 总览")
print("-" * 40)
print(f" 📝 发布数量: {stats['total_posts']}")
print(f" ⬇️ 总下载量: {stats['total_downloads']}")
print(f" 👁️ 总浏览量: {stats['total_views']}")
print(f" 👍 总点赞数: {stats['total_upvotes']}")
print(f" 💾 总收藏数: {stats['total_saves']}")
print(f" 💬 总评论数: {stats['total_comments']}")
print()
# 按类型分类
print("📂 按类型分类")
print("-" * 40)
for post_type, count in stats["by_type"].items():
print(f"{post_type}: {count}")
print()
# 详细列表
print("📋 发布列表 (按下载量排序)")
print("-" * 60)
# 表头
print(f"{'排名':<4} {'标题':<30} {'下载':<8} {'浏览':<8} {'点赞':<6}")
print("-" * 60)
for i, post in enumerate(stats["posts"], 1):
title = (
post["title"][:28] + ".." if len(post["title"]) > 30 else post["title"]
)
print(
f"{i:<4} {title:<30} {post['downloads']:<8} {post['views']:<8} {post['upvotes']:<6}"
)
print("=" * 60)
def generate_markdown(self, stats: dict, lang: str = "zh") -> str:
"""
生成 Markdown 格式报告
Args:
stats: 统计数据
lang: 语言 ("zh" 中文, "en" 英文)
"""
# 中英文文本
texts = {
"zh": {
"title": "# 📊 OpenWebUI 社区统计报告",
"updated": f"> 📅 更新时间: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"overview_title": "## 📈 总览",
"overview_header": "| 指标 | 数值 |",
"posts": "📝 发布数量",
"downloads": "⬇️ 总下载量",
"views": "👁️ 总浏览量",
"upvotes": "👍 总点赞数",
"saves": "💾 总收藏数",
"comments": "💬 总评论数",
"type_title": "## 📂 按类型分类",
"list_title": "## 📋 发布列表",
"list_header": "| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |",
},
"en": {
"title": "# 📊 OpenWebUI Community Stats Report",
"updated": f"> 📅 Updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"overview_title": "## 📈 Overview",
"overview_header": "| Metric | Value |",
"posts": "📝 Total Posts",
"downloads": "⬇️ Total Downloads",
"views": "👁️ Total Views",
"upvotes": "👍 Total Upvotes",
"saves": "💾 Total Saves",
"comments": "💬 Total Comments",
"type_title": "## 📂 By Type",
"list_title": "## 📋 Posts List",
"list_header": "| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |",
},
}
t = texts.get(lang, texts["en"])
md = []
md.append(t["title"])
md.append("")
md.append(t["updated"])
md.append("")
# 总览
md.append(t["overview_title"])
md.append("")
md.append(t["overview_header"])
md.append("|------|------|")
md.append(f"| {t['posts']} | {stats['total_posts']} |")
md.append(f"| {t['downloads']} | {stats['total_downloads']} |")
md.append(f"| {t['views']} | {stats['total_views']} |")
md.append(f"| {t['upvotes']} | {stats['total_upvotes']} |")
md.append(f"| {t['saves']} | {stats['total_saves']} |")
md.append(f"| {t['comments']} | {stats['total_comments']} |")
md.append("")
# 按类型分类
md.append(t["type_title"])
md.append("")
for post_type, count in stats["by_type"].items():
md.append(f"- **{post_type}**: {count}")
md.append("")
# 详细列表
md.append(t["list_title"])
md.append("")
md.append(t["list_header"])
md.append("|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|")
for i, post in enumerate(stats["posts"], 1):
title_link = f"[{post['title']}]({post['url']})"
md.append(
f"| {i} | {title_link} | {post['type']} | {post['version']} | "
f"{post['downloads']} | {post['views']} | {post['upvotes']} | "
f"{post['saves']} | {post['updated_at']} |"
)
md.append("")
return "\n".join(md)
def save_json(self, stats: dict, filepath: str):
"""保存 JSON 格式数据"""
with open(filepath, "w", encoding="utf-8") as f:
json.dump(stats, f, ensure_ascii=False, indent=2)
print(f"✅ JSON 数据已保存到: {filepath}")
def generate_shields_endpoints(self, stats: dict, output_dir: str = "docs/badges"):
"""
生成 Shields.io endpoint JSON 文件
Args:
stats: 统计数据
output_dir: 输出目录
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
def format_number(n: int) -> str:
"""格式化数字为易读格式"""
if n >= 1000000:
return f"{n/1000000:.1f}M"
elif n >= 1000:
return f"{n/1000:.1f}k"
return str(n)
# 各种徽章数据
badges = {
"downloads": {
"schemaVersion": 1,
"label": "downloads",
"message": format_number(stats["total_downloads"]),
"color": "blue",
"namedLogo": "openwebui",
},
"plugins": {
"schemaVersion": 1,
"label": "plugins",
"message": str(stats["total_posts"]),
"color": "green",
},
"followers": {
"schemaVersion": 1,
"label": "followers",
"message": format_number(stats.get("user", {}).get("followers", 0)),
"color": "blue",
},
"points": {
"schemaVersion": 1,
"label": "points",
"message": format_number(stats.get("user", {}).get("total_points", 0)),
"color": "orange",
},
"upvotes": {
"schemaVersion": 1,
"label": "upvotes",
"message": format_number(stats["total_upvotes"]),
"color": "brightgreen",
},
}
for name, data in badges.items():
filepath = Path(output_dir) / f"{name}.json"
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
print(f" 📊 Generated badge: {name}.json")
print(f"✅ Shields.io endpoints saved to: {output_dir}/")
def generate_readme_stats(self, stats: dict, lang: str = "zh") -> str:
"""
生成 README 统计徽章区域
Args:
stats: 统计数据
lang: 语言 ("zh" 中文, "en" 英文)
"""
# 获取 Top 6 插件
top_plugins = stats["posts"][:6]
# 中英文文本
texts = {
"zh": {
"title": "## 📊 社区统计",
"updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"author_header": "| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |",
"header": "| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |",
"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 |",
"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)*",
},
}
t = texts.get(lang, texts["en"])
user = stats.get("user", {})
lines = []
lines.append("<!-- STATS_START -->")
lines.append(t["title"])
lines.append("")
lines.append(t["updated"])
lines.append("")
# 作者信息表格
if user:
username = user.get("username", "")
profile_url = user.get("profile_url", "")
lines.append(t["author_header"])
lines.append("|:---:|:---:|:---:|:---:|")
lines.append(
f"| [{username}]({profile_url}) | **{user.get('followers', 0)}** | "
f"**{user.get('total_points', 0)}** | **{user.get('contributions', 0)}** |"
)
lines.append("")
# 统计徽章表格
lines.append(t["header"])
lines.append("|:---:|:---:|:---:|:---:|:---:|")
lines.append(
f"| **{stats['total_posts']}** | **{stats['total_downloads']}** | "
f"**{stats['total_views']}** | **{stats['total_upvotes']}** | **{stats['total_saves']}** |"
)
lines.append("")
# Top 6 热门插件
lines.append(t["top6_title"])
lines.append("")
lines.append(t["top6_header"])
lines.append("|:---:|------|:---:|:---:|")
medals = ["🥇", "🥈", "🥉", "4", "5", "6"]
for i, post in enumerate(top_plugins):
medal = medals[i] if i < len(medals) else str(i + 1)
lines.append(
f"| {medal} | [{post['title']}]({post['url']}) | {post['downloads']} | {post['views']} |"
)
lines.append("")
lines.append(t["full_stats"])
lines.append("<!-- STATS_END -->")
return "\n".join(lines)
def update_readme(self, stats: dict, readme_path: str, lang: str = "zh"):
"""
更新 README 文件中的统计区域
Args:
stats: 统计数据
readme_path: README 文件路径
lang: 语言 ("zh" 中文, "en" 英文)
"""
import re
# 读取现有内容
with open(readme_path, "r", encoding="utf-8") as f:
content = f.read()
# 生成新的统计区域
new_stats = self.generate_readme_stats(stats, lang)
# 检查是否已有统计区域
pattern = r"<!-- STATS_START -->.*?<!-- STATS_END -->"
if re.search(pattern, content, re.DOTALL):
# 替换现有区域
new_content = re.sub(pattern, new_stats, content, flags=re.DOTALL)
else:
# 在简介段落之后插入统计区域
# 查找模式:标题 -> 语言切换行 -> 简介段落 -> 插入位置
lines = content.split("\n")
insert_pos = 0
found_intro = False
for i, line in enumerate(lines):
# 跳过标题
if line.startswith("# "):
continue
# 跳过空行
if line.strip() == "":
continue
# 跳过语言切换行 (如 "English | [中文]" 或 "[English] | 中文")
if ("English" in line or "中文" in line) and "|" in line:
continue
# 找到第一个非空、非标题、非语言切换的段落(简介)
if not found_intro:
found_intro = True
# 继续到这个段落结束
continue
# 简介段落后的空行或下一个标题就是插入位置
if line.strip() == "" or line.startswith("#"):
insert_pos = i
break
# 如果没找到合适位置就放在第3行标题和语言切换后
if insert_pos == 0:
insert_pos = 3
# 在适当位置插入
lines.insert(insert_pos, "")
lines.insert(insert_pos + 1, new_stats)
lines.insert(insert_pos + 2, "")
new_content = "\n".join(lines)
# 写回文件
with open(readme_path, "w", encoding="utf-8") as f:
f.write(new_content)
print(f"✅ README 已更新: {readme_path}")
def main():
"""主函数"""
# 获取配置
api_key = os.getenv("OPENWEBUI_API_KEY")
user_id = os.getenv("OPENWEBUI_USER_ID")
if not api_key:
print("❌ 错误: 未设置 OPENWEBUI_API_KEY 环境变量")
print("请设置环境变量:")
print(" export OPENWEBUI_API_KEY='your_api_key_here'")
return 1
if not user_id:
print("❌ 错误: 未设置 OPENWEBUI_USER_ID 环境变量")
print("请设置环境变量:")
print(" export OPENWEBUI_USER_ID='your_user_id_here'")
print("\n提示: 用户 ID 可以从之前的 curl 请求中获取")
print(" 例如: b15d1348-4347-42b4-b815-e053342d6cb0")
return 1
# 初始化
stats_client = OpenWebUIStats(api_key, user_id)
print(f"🔍 用户 ID: {stats_client.user_id}")
# 获取所有帖子
print("📥 正在获取帖子数据...")
posts = stats_client.get_all_posts()
print(f"✅ 获取到 {len(posts)} 个帖子")
# 生成统计
stats = stats_client.generate_stats(posts)
# 打印到终端
stats_client.print_stats(stats)
# 保存 Markdown 报告 (中英文双版本)
script_dir = Path(__file__).parent.parent
# 中文报告
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.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)
print(f"✅ 英文报告已保存到: {md_en_path}")
# 保存 JSON 数据
json_path = script_dir / "docs" / "community-stats.json"
stats_client.save_json(stats, str(json_path))
# 生成 Shields.io endpoint JSON (用于动态徽章)
badges_dir = script_dir / "docs" / "badges"
stats_client.generate_shields_endpoints(stats, str(badges_dir))
# 更新 README 文件
readme_path = script_dir / "README.md"
readme_cn_path = script_dir / "README_CN.md"
stats_client.update_readme(stats, str(readme_path), lang="en")
stats_client.update_readme(stats, str(readme_cn_path), lang="zh")
return 0
if __name__ == "__main__":
exit(main())

232
scripts/publish_plugin.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Publish plugins to OpenWebUI Community
使用 OpenWebUICommunityClient 发布插件到官方社区
用法:
python scripts/publish_plugin.py # 更新已发布的插件(版本变化时)
python scripts/publish_plugin.py --force # 强制更新所有已发布的插件
python scripts/publish_plugin.py --new plugins/actions/xxx # 首次发布指定目录的新插件
python scripts/publish_plugin.py --new plugins/actions/xxx --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 get_client
def find_existing_plugins(plugins_dir: str) -> list:
"""查找所有已发布的插件文件(有 openwebui_id 的)"""
plugins = []
for root, _, files in os.walk(plugins_dir):
for file in files:
if file.endswith(".py") and not file.startswith("__"):
file_path = os.path.join(root, file)
with open(file_path, "r", encoding="utf-8") as f:
content = f.read(2000)
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 find_new_plugins_in_dir(target_dir: str) -> list:
"""查找指定目录中没有 openwebui_id 的新插件"""
plugins = []
if not os.path.isdir(target_dir):
print(f"Error: {target_dir} is not a directory")
return plugins
for file in os.listdir(target_dir):
if file.endswith(".py") and not file.startswith("__"):
file_path = os.path.join(target_dir, file)
if not os.path.isfile(file_path):
continue
with open(file_path, "r", encoding="utf-8") as f:
content = f.read(2000)
# 检查是否有 frontmatter (title)
title_match = re.search(r"title:\s*(.+)", content)
if not title_match:
continue
# 检查是否已有 ID
id_match = re.search(r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content)
if id_match:
print(f" ⚠️ {file} already has ID, will update instead")
plugins.append(
{
"file_path": file_path,
"title": title_match.group(1).strip(),
"post_id": id_match.group(1).strip(),
"is_new": False,
}
)
else:
plugins.append(
{
"file_path": file_path,
"title": title_match.group(1).strip(),
"post_id": None,
"is_new": True,
}
)
return plugins
def main():
parser = argparse.ArgumentParser(
description="Publish plugins to OpenWebUI Market",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Update existing plugins (with version check)
python scripts/publish_plugin.py
# Force update all existing plugins
python scripts/publish_plugin.py --force
# Publish new plugins from a specific directory
python scripts/publish_plugin.py --new plugins/actions/summary
# Preview what would be done
python scripts/publish_plugin.py --new plugins/actions/summary --dry-run
""",
)
parser.add_argument(
"--force", action="store_true", help="Force update even if version matches"
)
parser.add_argument(
"--new",
metavar="DIR",
help="Publish new plugins from the specified directory (required for first-time publishing)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without actually publishing",
)
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")
updated = 0
created = 0
skipped = 0
failed = 0
# 处理新插件发布
if args.new:
target_dir = args.new
if not os.path.isabs(target_dir):
target_dir = os.path.join(base_dir, target_dir)
print(f"🆕 Publishing new plugins from: {target_dir}\n")
new_plugins = find_new_plugins_in_dir(target_dir)
if not new_plugins:
print("No plugins found in the specified directory.")
return
for plugin in new_plugins:
file_path = plugin["file_path"]
file_name = os.path.basename(file_path)
title = plugin["title"]
is_new = plugin.get("is_new", True)
if is_new:
print(f"🆕 Creating: {file_name} ({title})")
else:
print(f"📦 Updating: {file_name} (ID: {plugin['post_id'][:8]}...)")
if args.dry_run:
print(f" [DRY-RUN] Would {'create' if is_new else 'update'}")
continue
success, message = client.publish_plugin_from_file(
file_path, force=args.force, auto_create=True
)
if success:
if "Created" in message:
print(f" 🎉 {message}")
created += 1
elif "Skipped" in message:
print(f" ⏭️ {message}")
skipped += 1
else:
print(f"{message}")
updated += 1
else:
print(f"{message}")
failed += 1
# 处理已有插件更新
else:
existing_plugins = find_existing_plugins(plugins_dir)
print(f"Found {len(existing_plugins)} existing plugins with OpenWebUI ID.\n")
if not existing_plugins:
print("No existing plugins to update.")
print(
"\n💡 Tip: Use --new <dir> to publish new plugins from a specific directory"
)
return
for plugin in existing_plugins:
file_path = plugin["file_path"]
file_name = os.path.basename(file_path)
post_id = plugin["post_id"]
print(f"📦 {file_name} (ID: {post_id[:8]}...)")
if args.dry_run:
print(f" [DRY-RUN] Would update")
continue
success, message = client.publish_plugin_from_file(
file_path, force=args.force, auto_create=False # 不自动创建,只更新
)
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: {created} created, {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()