Compare commits

...

30 Commits

Author SHA1 Message Date
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
21 changed files with 2276 additions and 192 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.

View File

@@ -949,7 +949,7 @@ async def action(self, body, __event_call__, __metadata__, ...):
#### 优势
- **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块
- **自包含**:图片以 Base64 Data URL 嵌入,无外部依赖
- **高效存储**:图片上传至 `/api/v1/files`,避免 Base64 字符串膨胀聊天记录
- **持久化**:通过 API 回写,消息重新加载后图片仍然存在
- **跨平台**:任何支持 Markdown 图片的客户端都能显示
- **无服务端渲染依赖**:利用用户浏览器的渲染能力
@@ -960,7 +960,7 @@ async def action(self, body, __event_call__, __metadata__, ...):
|------|-------------------------|------------------------|
| 输出格式 | HTML 代码块 | Markdown 图片 |
| 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 |
| 外部依赖 | 需要加载 JS 库 | 无(图片自包含) |
| 外部依赖 | 需要加载 JS 库 | 依赖 `/api/v1/files` 存储 |
| 持久化 | 依赖浏览器渲染 | ✅ 永久可见 |
| 文件导出 | 需特殊处理 | ✅ 直接导出 |
| 适用场景 | 交互式内容 | 信息图、图表快照 |
@@ -970,7 +970,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)
---

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

@@ -0,0 +1,54 @@
# OpenWebUI 社区统计报告自动生成
# 每小时自动获取并更新社区统计数据
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: 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 changes
id: check_changes
run: |
git diff --quiet docs/community-stats.md docs/community-stats.en.md README.md README_CN.md || echo "changed=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.check_changes.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add docs/community-stats.md docs/community-stats.en.md docs/community-stats.json README.md README_CN.md
git commit -m "📊 更新社区统计数据 $(date +'%Y-%m-%d')"
git push

View File

@@ -180,14 +180,23 @@ 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
EXISTING_COUNT=$(gh release list --limit 100 | grep -c "^${TODAY_PREFIX}" || echo "0")
NEXT_NUM=$((EXISTING_COUNT + 1))
VERSION="${TODAY_PREFIX}${NEXT_NUM}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Release version: $VERSION"

View File

@@ -4,7 +4,31 @@ 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-06 21:19
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **43** | **62** | **17** |
| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |
|:---:|:---:|:---:|:---:|:---:|
| **11** | **794** | **8481** | **54** | **48** |
### 🔥 Top 5 Popular Plugins
| Rank | Plugin | Downloads | Views |
|:---:|------|:---:|:---:|
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 240 | 2133 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 171 | 459 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1236 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 76 | 1421 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 909 |
*See full stats in [Community Stats Report](./docs/community-stats.md)*
<!-- STATS_END -->
## 📦 Project Contents
@@ -77,3 +101,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,37 @@
[English](./README.md) | 中文
OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Plugins)
OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词等资源。
<!-- STATS_START -->
## 📊 社区统计
> 🕐 自动更新于 2026-01-06 21:19
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
|:---:|:---:|:---:|:---:|
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **43** | **62** | **17** |
| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |
|:---:|:---:|:---:|:---:|:---:|
| **11** | **794** | **8481** | **54** | **48** |
### 🔥 热门插件 Top 5
| 排名 | 插件 | 下载 | 浏览 |
|:---:|------|:---:|:---:|
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 240 | 2133 |
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 171 | 459 |
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1236 |
| 4⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 76 | 1421 |
| 5⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 909 |
*完整统计请查看 [社区统计报告](./docs/community-stats.md)*
<!-- STATS_END -->
## 📦 项目内容
### 🧩 插件 (Plugins)
位于 `plugins/` 目录,包含各类 Python 编写的功能增强插件:
@@ -19,7 +49,6 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
- **Context Enhancement** (`context_enhancement_filter`): 上下文增强过滤器。
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Gemini Manifold 配套增强。
#### Pipes (模型管道)
- **Gemini Manifold** (`gemini_mainfold`): 集成 Gemini 模型的管道。
@@ -31,40 +60,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 +72,7 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
- **[从问一个AI到运营一支AI团队](./docs/zh/从问一个AI到运营一支AI团队.md)** - 深度运营经验分享。
更多示例请查看 `docs/examples/` 目录。
## 🚀 快速开始
本项目是一个资源集合,无需安装 Python 环境。你只需要下载对应的文件并导入到你的 OpenWebUI 实例中即可。
@@ -104,3 +103,5 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
1. Fork 本仓库。
2. 将你的文件添加到对应的 `prompts/``plugins/` 目录。
3. 提交 Pull Request。
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)

View File

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

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

@@ -0,0 +1,203 @@
{
"total_posts": 11,
"total_downloads": 794,
"total_views": 8481,
"total_upvotes": 54,
"total_downvotes": 1,
"total_saves": 48,
"total_comments": 13,
"by_type": {
"action": 9,
"filter": 2
},
"posts": [
{
"title": "Turn Any Text into Beautiful Mind Maps",
"slug": "turn_any_text_into_beautiful_mind_maps_3094c59a",
"type": "action",
"version": "0.8.2",
"author": "Fu-Jie",
"description": "Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.",
"downloads": 240,
"views": 2133,
"upvotes": 10,
"saves": 15,
"comments": 8,
"created_at": "2025-12-30",
"updated_at": "2026-01-03",
"url": "https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a"
},
{
"title": "Export to Excel",
"slug": "export_mulit_table_to_excel_244b8f9d",
"type": "action",
"version": "0.3.6",
"author": "Fu-Jie",
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
"downloads": 171,
"views": 459,
"upvotes": 3,
"saves": 3,
"comments": 0,
"created_at": "2025-05-30",
"updated_at": "2026-01-03",
"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": "This filter automatically compresses long conversation contexts by intelligently summarizing and removing intermediate messages while preserving critical information, thereby significantly reducing token consumption.",
"downloads": 112,
"views": 1236,
"upvotes": 5,
"saves": 9,
"comments": 0,
"created_at": "2025-11-08",
"updated_at": "2025-12-31",
"url": "https://openwebui.com/posts/async_context_compression_b1655bc8"
},
{
"title": "Flash Card ",
"slug": "flash_card_65a2ea8f",
"type": "action",
"version": "0.2.4",
"author": "Fu-Jie",
"description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
"downloads": 76,
"views": 1421,
"upvotes": 8,
"saves": 5,
"comments": 2,
"created_at": "2025-12-30",
"updated_at": "2026-01-03",
"url": "https://openwebui.com/posts/flash_card_65a2ea8f"
},
{
"title": "Smart Infographic",
"slug": "smart_infographic_ad6f0c7f",
"type": "action",
"version": "1.3.2",
"author": "jeff",
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
"downloads": 65,
"views": 909,
"upvotes": 6,
"saves": 8,
"comments": 2,
"created_at": "2025-12-28",
"updated_at": "2026-01-03",
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
},
{
"title": "Export to Word (Enhanced Formatting)",
"slug": "export_to_word_enhanced_formatting_fca6a315",
"type": "action",
"version": "0.4.0",
"author": "Fu-Jie",
"description": "Export the current conversation to a formatted Word doc with syntax highlighting, AI-generated titles, and perfect Markdown rendering (tables, quotes, lists).",
"downloads": 51,
"views": 504,
"upvotes": 5,
"saves": 4,
"comments": 0,
"created_at": "2026-01-03",
"updated_at": "2026-01-05",
"url": "https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315"
},
{
"title": "智能信息图",
"slug": "智能信息图_e04a48ff",
"type": "action",
"version": "1.3.1",
"author": "jeff",
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
"downloads": 33,
"views": 398,
"upvotes": 3,
"saves": 0,
"comments": 0,
"created_at": "2025-12-28",
"updated_at": "2025-12-29",
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
},
{
"title": "导出为 Word-支持公式、流程图、表格和代码块",
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
"type": "action",
"version": "0.4.1",
"author": "Fu-Jie",
"description": "将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持中英文无乱码。",
"downloads": 15,
"views": 752,
"upvotes": 7,
"saves": 1,
"comments": 1,
"created_at": "2026-01-04",
"updated_at": "2026-01-05",
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
},
{
"title": "智能生成交互式思维导图,帮助用户可视化知识",
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
"type": "action",
"version": "0.8.0",
"author": "",
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
"downloads": 14,
"views": 249,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2025-12-31",
"updated_at": "2025-12-31",
"url": "https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b"
},
{
"title": "闪记卡生成插件",
"slug": "闪记卡生成插件_4a31eac3",
"type": "action",
"version": "0.2.2",
"author": "Fu-Jie",
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
"downloads": 12,
"views": 309,
"upvotes": 3,
"saves": 1,
"comments": 0,
"created_at": "2025-12-30",
"updated_at": "2025-12-31",
"url": "https://openwebui.com/posts/闪记卡生成插件_4a31eac3"
},
{
"title": "异步上下文压缩",
"slug": "异步上下文压缩_5c0617cb",
"type": "filter",
"version": "1.1.0",
"author": "Fu-Jie",
"description": "在 LLM 响应完成后进行上下文摘要和压缩",
"downloads": 5,
"views": 111,
"upvotes": 2,
"saves": 1,
"comments": 0,
"created_at": "2025-11-08",
"updated_at": "2025-12-31",
"url": "https://openwebui.com/posts/异步上下文压缩_5c0617cb"
}
],
"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": 43,
"following": 2,
"total_points": 62,
"post_points": 53,
"comment_points": 9,
"contributions": 17
}
}

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

@@ -0,0 +1,35 @@
# 📊 OpenWebUI 社区统计报告
> 📅 更新时间: 2026-01-06 21:19
## 📈 总览
| 指标 | 数值 |
|------|------|
| 📝 发布数量 | 11 |
| ⬇️ 总下载量 | 794 |
| 👁️ 总浏览量 | 8481 |
| 👍 总点赞数 | 54 |
| 💾 总收藏数 | 48 |
| 💬 总评论数 | 13 |
## 📂 按类型分类
- **action**: 9
- **filter**: 2
## 📋 发布列表
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.8.2 | 240 | 2133 | 10 | 15 | 2026-01-03 |
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 171 | 459 | 3 | 3 | 2026-01-03 |
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1236 | 5 | 9 | 2025-12-31 |
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 76 | 1421 | 8 | 5 | 2026-01-03 |
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 909 | 6 | 8 | 2026-01-03 |
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 504 | 5 | 4 | 2026-01-05 |
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 398 | 3 | 0 | 2025-12-29 |
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 15 | 752 | 7 | 1 | 2026-01-05 |
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 249 | 2 | 1 | 2025-12-31 |
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 309 | 3 | 1 | 2025-12-31 |
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 111 | 2 | 1 | 2025-12-31 |

View File

@@ -1,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.1</span>
Export conversation to Word (.docx) with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.

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.1</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 设置)
- `启用数学公式`, `启用行内公式`
---

View File

@@ -63,7 +63,7 @@ Actions are interactive plugins that:
Export the current conversation to a formatted Word doc with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
**Version:** 0.4.0
**Version:** 0.4.1
[:octicons-arrow-right-24: Documentation](export-to-word.md)

View File

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

View File

@@ -20,28 +20,28 @@
您可以通过插件设置中的 **Valves** 按钮配置以下选项:
- **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`
- **最大嵌入图片大小MB**:嵌入图片的最大大小 (MB)。默认:`20`
- **界面语言**:界面语言,支持 `en` (英语) 和 `zh` (中文)。默认:`zh`
- **英文字体**:英文字体名称。默认:`Calibri`
- **中文字体**:中文字体名称。默认:`SimSun`
- **代码字体**:代码字体名称。默认:`Consolas`
- **表头背景色**:表头背景色(十六进制,不带#)。默认:`F2F2F2`
- **表格隔行背景色**:表格隔行背景色(十六进制,不带#)。默认:`FBFBFB`
- **Mermaid_JS地址**Mermaid.js 库的 URL。
- **JSZip库地址**JSZip 库的 URL用于 DOCX 操作)。
- **Mermaid_PNG缩放比例**Mermaid PNG 生成缩放比例(分辨率)。默认:`3.0`
- **Mermaid显示比例**Mermaid 在 Word 中的显示比例(视觉大小)。默认:`1.0`
- **Mermaid布局优化**:自动将 LR左右流程图转换为 TD上下。默认`False`
- **Mermaid背景色**Mermaid 图表背景色(如 `white`, `transparent`)。默认:`transparent`
- **启用Mermaid图注**:启用/禁用 Mermaid 图表的图注。默认:`True`
- **Mermaid图注样式**Mermaid 图注的段落样式名称。默认:`Caption`
- **Mermaid图注前缀**:图注前缀(如 '图')。留空则根据语言自动检测。
- **启用数学公式**:启用 LaTeX 数学公式块转换(`\[...\]``$$...$$`)。默认:`True`
- **启用行内公式**:启用行内 `$ ... $` 数学公式转换。默认:`True`
## 支持的 Markdown 语法
@@ -88,6 +88,10 @@
## 更新日志
### v0.4.1
- **中文参数名**: 将插件配置项名称和描述全部汉化,提升中文用户体验。
### v0.4.0
- **多语言支持**: 新增界面语言切换(中文/英文),提示信息更友好。

View File

@@ -3,7 +3,7 @@ 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.1
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.

View File

@@ -3,7 +3,7 @@ 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.1
icon_url: data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB3aWR0aD0iMjQiCiAgaGVpZ2h0PSIyNCIKICB2aWV3Qm94PSIwIDAgMjQgMjQiCiAgZmlsbD0ibm9uZSIKICBzdHJva2U9ImN1cnJlbnRDb2xvciIKICBzdHJva2Utd2lkdGg9IjIiCiAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgogIHN0cm9rZS1saW5lam9pbj0icm91bmQiCj4KICA8cGF0aCBkPSJNNiAyMmEyIDIgMCAwIDEtMi0yVjRhMiAyIDAgMCAxIDItMmg4YTIuNCAyLjQgMCAwIDEgMS43MDQuNzA2bDMuNTg4IDMuNTg4QTIuNCAyLjQgMCAwIDEgMjAgOHYxMmEyIDIgMCAwIDEtMiAyeiIgLz4KICA8cGF0aCBkPSJNMTQgMnY1YTEgMSAwIDAgMCAxIDFoNSIgLz4KICA8cGF0aCBkPSJNMTAgOUg4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxM0g4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxN0g4IiAvPgo8L3N2Zz4K
requirements: python-docx, Pygments, latex2mathml, mathml2omml
description: 将对话导出为 Word (.docx),支持 Mermaid 图表 (客户端渲染 SVG+PNG)、LaTeX 数学公式、真实超链接、增强表格格式、代码高亮和引用块。
@@ -135,147 +135,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)",
)
@@ -345,7 +345,7 @@ 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.界面语言)
if __event_emitter__:
last_assistant_message = body["messages"][-1]
@@ -381,22 +381,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 +450,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 +1006,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:
@@ -1149,7 +1149,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 +1239,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 +1689,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 +1833,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 +1872,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 +1944,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 +1972,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 +1997,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 +2052,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 +2190,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 +2435,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 +2507,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 +2536,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 +2551,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 +2566,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

@@ -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.
@@ -20,6 +20,7 @@ Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes
-**Real-time Rendering**: Renders mind maps directly in the chat interface without navigation
-**Export Capabilities**: Supports PNG, SVG code, and Markdown source export
-**Customizable Configuration**: Configurable LLM model, minimum text length, and other parameters
-**Image Output Mode**: Generate static SVG images embedded directly in Markdown (no interactive HTML)
---
@@ -80,6 +81,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 +279,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
> **重要提示**:为了确保所有插件的可维护性和易用性,每个插件都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
@@ -20,6 +20,7 @@
-**实时渲染**:在聊天界面中直接渲染思维导图,无需跳转
-**导出功能**:支持 PNG、SVG 代码和 Markdown 源码导出
-**自定义配置**:可配置 LLM 模型、最小文本长度等参数
-**图片输出模式**:生成静态 SVG 图片直接嵌入 Markdown无交互式 HTML
---
@@ -80,6 +81,7 @@
| `MIN_TEXT_LENGTH` | `100` | 进行思维导图分析所需的最小文本长度(字符数)。文本过短将无法生成有效的导图。 |
| `CLEAR_PREVIOUS_HTML` | `false` | 在生成新的思维导图时,是否清除之前由插件生成的 HTML 内容。 |
| `MESSAGE_COUNT` | `1` | 用于生成思维导图的最近消息数量1-5。 |
| `OUTPUT_MODE` | `html` | 输出模式:`html` 为交互式 HTML默认`image` 为嵌入静态 Markdown 图片。 |
---
@@ -277,6 +279,32 @@
## 更新日志
### v0.9.1
**新功能:图片输出模式**
- 新增 `OUTPUT_MODE` 配置参数,支持两种模式:
- `html`(默认):交互式 HTML 思维导图,带完整控制面板
- `image`:静态 SVG 图片直接嵌入 Markdown上传至 `/api/v1/files`
- 图片模式特性:
- 自动响应式宽度(适应聊天容器)
- 自动主题检测(亮色/暗色)
- 通过 Chat API 持久化存储(刷新页面后保留)
- 高效文件存储(聊天记录中无超长 Base64 字符串)
**改进项:**
- 实现健壮的 Chat API 更新机制,带重试逻辑
- 修复消息持久化,同时更新 `messages[]``history.messages`
- 添加 Event API 实现即时前端更新
- 移除不必要的 `SVG_WIDTH``SVG_HEIGHT` 参数(现已自动计算)
**技术细节:**
- 图片模式使用 `__event_call__` 在浏览器中执行 JavaScript
- SVG 离屏渲染,转换为 Blob并上传至 OpenWebUI Files API
- 通过 OpenWebUI Backend-Controlled API 流程更新聊天消息为 `/api/v1/files/{id}/content` URL
### v0.8.2
- 移除输出中的调试信息

View File

@@ -3,7 +3,7 @@ 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
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 +13,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 +786,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 +818,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 +945,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 +1511,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 +1561,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)}"

View File

@@ -3,7 +3,7 @@ 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
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
"""
@@ -13,7 +13,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 +443,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 +485,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 +771,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 +817,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 +886,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 +909,7 @@ class Action:
user_language: str = "zh-CN",
) -> str:
"""
将新内容合并到现有的 HTML 容器中或者创建一个新的容器。
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
"""
if (
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
@@ -900,14 +942,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 +1343,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 +1351,7 @@ class Action:
current_timezone_str = "未知时区"
await self._emit_notification(
__event_emitter__, "思维导图已启动正在为您生成思维导图...", "info"
__event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info"
)
messages = body.get("messages")
@@ -980,7 +1400,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 +1441,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 +1504,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

550
scripts/openwebui_stats.py Normal file
View File

@@ -0,0 +1,550 @@
#!/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", {}).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_readme_stats(self, stats: dict, lang: str = "zh") -> str:
"""
生成 README 统计徽章区域
Args:
stats: 统计数据
lang: 语言 ("zh" 中文, "en" 英文)
"""
# 获取 Top 5 插件
top_plugins = stats["posts"][:5]
# 中英文文本
texts = {
"zh": {
"title": "## 📊 社区统计",
"updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"author_header": "| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |",
"header": "| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |",
"top5_title": "### 🔥 热门插件 Top 5",
"top5_header": "| 排名 | 插件 | 下载 | 浏览 |",
"full_stats": "*完整统计请查看 [社区统计报告](./docs/community-stats.md)*",
},
"en": {
"title": "## 📊 Community Stats",
"updated": f"> 🕐 Auto-updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
"author_header": "| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |",
"header": "| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |",
"top5_title": "### 🔥 Top 5 Popular Plugins",
"top5_header": "| Rank | Plugin | Downloads | Views |",
"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 5 热门插件
lines.append(t["top5_title"])
lines.append("")
lines.append(t["top5_header"])
lines.append("|:---:|------|:---:|:---:|")
medals = ["🥇", "🥈", "🥉", "4", "5"]
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.md"
md_zh_content = stats_client.generate_markdown(stats, lang="zh")
with open(md_zh_path, "w", encoding="utf-8") as f:
f.write(md_zh_content)
print(f"\n✅ 中文报告已保存到: {md_zh_path}")
# 英文报告
md_en_path = script_dir / "docs" / "community-stats.en.md"
md_en_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))
# 更新 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())