Compare commits
150 Commits
v2026.01.0
...
v2026.01.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2be1b25b1 | ||
|
|
700a7fc27a | ||
|
|
06cc48bab1 | ||
|
|
498e433ed3 | ||
|
|
4e915ea7a9 | ||
|
|
825ea07f4b | ||
|
|
1a731c181b | ||
|
|
c59ba5e501 | ||
|
|
e21e3e2ffa | ||
|
|
d2abaa138e | ||
|
|
3843ae5bc7 | ||
|
|
02c7a87c63 | ||
|
|
1e59025535 | ||
|
|
46195791b6 | ||
|
|
85b6bcece1 | ||
|
|
fece7d9898 | ||
|
|
d41822911c | ||
|
|
7b1180a1c8 | ||
|
|
6d5c3f1415 | ||
|
|
f8157f92fc | ||
|
|
fa2e9f5344 | ||
|
|
9c37955cf2 | ||
|
|
261f74efe8 | ||
|
|
83727bdab1 | ||
|
|
3b1a8d795f | ||
|
|
f650c64ffe | ||
|
|
6000c880de | ||
|
|
048fbb26d7 | ||
|
|
a88eda62cc | ||
|
|
957fb2dfb7 | ||
|
|
d2be5109ad | ||
|
|
80fdc52598 | ||
|
|
2b90ead3cf | ||
|
|
2aa5d77586 | ||
|
|
2b1b1ef939 | ||
|
|
4e21e06617 | ||
|
|
82ce1cef29 | ||
|
|
533eace74e | ||
|
|
83b3dcda65 | ||
|
|
e89373e0ed | ||
|
|
4b66a2bb1c | ||
|
|
59ba23da63 | ||
|
|
f8a89e222c | ||
|
|
096568f3e6 | ||
|
|
e10e12ebc9 | ||
|
|
c4df5eba47 | ||
|
|
0da3d3d881 | ||
|
|
6f4a62d1bc | ||
|
|
5d71c2a4d3 | ||
|
|
097707c168 | ||
|
|
8f4cfceb50 | ||
|
|
4ab5fab7d0 | ||
|
|
0e293be8bc | ||
|
|
182c12f81a | ||
|
|
1337a90911 | ||
|
|
2f0a347ab3 | ||
|
|
4eda286512 | ||
|
|
0fead8158d | ||
|
|
031bef563a | ||
|
|
04c3fd2bf9 | ||
|
|
cbbf6118b5 | ||
|
|
4c529369ce | ||
|
|
797dea0d77 | ||
|
|
a91aee31de | ||
|
|
8511b7df80 | ||
|
|
afd1e7a444 | ||
|
|
34b2c3d6cf | ||
|
|
d5c099dd15 | ||
|
|
8810223693 | ||
|
|
84974a2fb9 | ||
|
|
af847293af | ||
|
|
a44e80ce5b | ||
|
|
c2815e13e9 | ||
|
|
56bfa3a3ef | ||
|
|
a13c915f27 | ||
|
|
fb2d35237e | ||
|
|
3f19ecfd20 | ||
|
|
2fd96f07aa | ||
|
|
a1c1ed9840 | ||
|
|
c63701d05f | ||
|
|
863805dc68 | ||
|
|
98f7dff458 | ||
|
|
08c0dd984c | ||
|
|
e870ad8823 | ||
|
|
d687fffdb5 | ||
|
|
d534d8b319 | ||
|
|
d5c5158726 | ||
|
|
888026876f | ||
|
|
06e8d30900 | ||
|
|
cbf2ff7f93 | ||
|
|
abbe3fb248 | ||
|
|
7e44dde979 | ||
|
|
3649d75539 | ||
|
|
d3b4219a9a | ||
|
|
9e98d55e11 | ||
|
|
4b8515f682 | ||
|
|
d2f35ce396 | ||
|
|
f479f23b38 | ||
|
|
51048f9e5d | ||
|
|
1118ae34c4 | ||
|
|
7a5e1a4e12 | ||
|
|
8e377e1794 | ||
|
|
d66360b02d | ||
|
|
1ece648006 | ||
|
|
a262a716a3 | ||
|
|
06fdfee182 | ||
|
|
7085e794a3 | ||
|
|
a9cae535eb | ||
|
|
bdbd0d98be | ||
|
|
51612ea783 | ||
|
|
baf364a85f | ||
|
|
f78e703a99 | ||
|
|
aabb24c9cd | ||
|
|
ef34cc326c | ||
|
|
5fa56ba88d | ||
|
|
b71df8ef43 | ||
|
|
8c6fe6784e | ||
|
|
29fa5bae29 | ||
|
|
dab465d924 | ||
|
|
77c0defe93 | ||
|
|
80cf2b5a52 | ||
|
|
96638d8092 | ||
|
|
21ad55ae55 | ||
|
|
530a6cd463 | ||
|
|
8615773b67 | ||
|
|
16eaec64b7 | ||
|
|
8558077dfe | ||
|
|
a15353ea52 | ||
|
|
5b44e3e688 | ||
|
|
a4b3628e01 | ||
|
|
bbb7db3878 | ||
|
|
dec2bbb4bf | ||
|
|
6a241b0ae0 | ||
|
|
51c53e0ed0 | ||
|
|
8cb6382e72 | ||
|
|
5889471e82 | ||
|
|
ca2e0b4fba | ||
|
|
10d24fbfa2 | ||
|
|
322bd6e167 | ||
|
|
3cc4478dd9 | ||
|
|
59f6f2ba97 | ||
|
|
172d9e0b41 | ||
|
|
de7086c9e1 | ||
|
|
5f63e8d1e2 | ||
|
|
3da0b894fd | ||
|
|
ad2d26aa16 | ||
|
|
a09f3e0bdb | ||
|
|
3a0faf27df | ||
|
|
cd3e7309a8 | ||
|
|
54cc10bb41 |
47
.all-contributorsrc
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"commitType": "docs",
|
||||
"commitConvention": "angular",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "rbb-dev",
|
||||
"name": "rbb-dev",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37469229?v=4",
|
||||
"profile": "https://github.com/rbb-dev",
|
||||
"contributions": [
|
||||
"ideas",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dhaern",
|
||||
"name": "Raxxoor",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7317522?v=4",
|
||||
"profile": "https://trade.xyz/?ref=BZ1RJRXWO",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "i-iooi-i",
|
||||
"name": "ZOLO",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1827701?v=4",
|
||||
"profile": "https://github.com/i-iooi-i",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"skipCi": true,
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"projectName": "awesome-openwebui",
|
||||
"projectOwner": "Fu-Jie"
|
||||
}
|
||||
124
.github/copilot-instructions.md
vendored
@@ -40,7 +40,7 @@ plugins/actions/export_to_docx/
|
||||
- 格式: `**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** x.x.x | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)`
|
||||
- **注意**: Author 和 Project 为固定值,仅需更新 Version 版本号
|
||||
3. **描述 (Description)**: 一句话功能介绍
|
||||
4. **最新更新 (What's New)**: **必须**放在描述之后,显著展示最新版本的变更点
|
||||
4. **最新更新 (What's New)**: **必须**放在描述之后,显著展示最新版本的变更点 (仅展示最近 3 次更新)
|
||||
5. **核心特性 (Key Features)**: 使用 Emoji + 粗体标题 + 描述格式
|
||||
6. **使用方法 (How to Use)**: 按步骤说明
|
||||
7. **配置参数 (Configuration/Valves)**: 使用表格格式,包含参数名、默认值、描述
|
||||
@@ -96,7 +96,7 @@ example code or syntax here
|
||||
|
||||
### 文档内容要求 (Content Requirements)
|
||||
|
||||
- **新增功能**: 必须在 "What's New" 章节中明确列出,使用 Emoji + 粗体标题格式。
|
||||
- **新增功能**: 必须在 "What's New" 章节中明确列出,使用 Emoji + 粗体标题格式 (仅保留最近 3 个版本的更新记录)。
|
||||
- **双语**: 必须提供 `README.md` (英文) 和 `README_CN.md` (中文)。
|
||||
- **表格对齐**: 配置参数表格使用左对齐 `:---`。
|
||||
- **Emoji 规范**: 标题使用合适的 Emoji 增强可读性。
|
||||
@@ -260,7 +260,46 @@ async def _emit_notification(
|
||||
|
||||
## 📋 日志规范 (Logging Standard)
|
||||
|
||||
- **禁止使用** `print()` 语句
|
||||
### 1. 前端控制台调试 (Frontend Console Debugging) - **优先推荐 (Preferred)**
|
||||
|
||||
对于需要实时查看数据流、排查 UI 交互或内容变更的场景,**优先使用**前端控制台日志。这种方式可以直接在浏览器 DevTools (F12) 中查看,无需访问服务端日志。
|
||||
|
||||
**实现方式**: 通过 `__event_emitter__` 发送 `type: "execute"` 事件执行 JS 代码。
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
async def _emit_debug_log(self, __event_emitter__, title: str, data: dict):
|
||||
"""在浏览器控制台打印结构化调试日志"""
|
||||
if not self.valves.show_debug_log or not __event_emitter__:
|
||||
return
|
||||
|
||||
try:
|
||||
js_code = f"""
|
||||
(async function() {{
|
||||
console.group("🛠️ {title}");
|
||||
console.log({json.dumps(data, ensure_ascii=False)});
|
||||
console.groupEnd();
|
||||
}})();
|
||||
"""
|
||||
|
||||
await __event_emitter__({
|
||||
"type": "execute",
|
||||
"data": {"code": js_code}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error emitting debug log: {e}")
|
||||
```
|
||||
|
||||
**配置要求**:
|
||||
- 在 `Valves` 中添加 `show_debug_log: bool` 开关,默认关闭。
|
||||
- 仅在开关开启时发送日志。
|
||||
|
||||
### 2. 服务端日志 (Server-side Logging)
|
||||
|
||||
用于记录系统级错误、异常堆栈或无需前端感知的后台任务。
|
||||
|
||||
- **禁止使用** `print()` 语句 (除非用于简单的脚本调试)
|
||||
- 必须使用 Python 标准库 `logging`
|
||||
|
||||
```python
|
||||
@@ -281,6 +320,43 @@ logger.error(f"Processing failed: {e}", exc_info=True)
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Filter 插件开发规范 (Filter Plugin Standards)
|
||||
|
||||
### 1. 状态管理 (State Management) - **关键 (Critical)**
|
||||
|
||||
Filter 实例在 OpenWebUI 生命周期中是**单例 (Singleton)**。这意味着同一个 Filter 实例会处理所有并发请求。
|
||||
|
||||
- **❌ 禁止 (Prohibited)**: 使用 `self` 存储请求级别的临时状态(如 `self.temp_state`)。这会导致严重的**竞态条件 (Race Conditions)**,即一个请求的数据被另一个请求覆盖。
|
||||
- **✅ 推荐 (Recommended)**:
|
||||
- **无状态设计**: `inlet` 和 `outlet` 应该尽可能独立。
|
||||
- **重新计算**: 在 `outlet` 中根据 `body['messages']` 重新计算所需的状态,而不是依赖 `inlet` 传递。
|
||||
- **元数据传递**: 如果必须传递状态,尝试使用 `body` 中的临时字段(需谨慎处理清理)或 `__metadata__`(如果可写)。
|
||||
|
||||
### 2. 摘要注入角色 (Summary Injection Role)
|
||||
|
||||
当注入历史摘要或上下文时:
|
||||
|
||||
- **❌ 避免 (Avoid)**: 使用 `system` 角色(部分模型对 system prompt 位置敏感或不支持中间插入)。
|
||||
- **❌ 避免 (Avoid)**: 使用 `user` 角色(容易混淆用户真实意图)。
|
||||
- **✅ 推荐 (Recommended)**: 使用 **`assistant`** 角色。这通常被模型视为上下文历史的一部分,兼容性最好。
|
||||
|
||||
### 3. 模型默认值 (Model Defaults)
|
||||
|
||||
- **❌ 禁止 (Prohibited)**: 硬编码特定模型 ID(如 `gpt-3.5-turbo`)作为默认值。这会导致非 OpenAI 用户出错。
|
||||
- **✅ 推荐 (Recommended)**:
|
||||
- 默认值设为 `None` 或空字符串。
|
||||
- 优先使用当前对话的模型 (`body.get("model")`)。
|
||||
- 如果必须指定,通过 `Valves` 让用户配置。
|
||||
|
||||
### 4. 异步处理 (Async Processing)
|
||||
|
||||
对于耗时的后台任务(如摘要生成、日志记录):
|
||||
|
||||
- **✅ 推荐 (Recommended)**: 在 `outlet` 中使用 `asyncio.create_task()` 启动后台任务,确保不阻塞用户响应。
|
||||
- **✅ 推荐 (Recommended)**: 在后台任务中捕获所有异常,防止崩溃影响主进程。
|
||||
|
||||
---
|
||||
|
||||
## 🎨 HTML 注入规范 (HTML Injection)
|
||||
|
||||
使用统一的标记和结构:
|
||||
@@ -1511,6 +1587,48 @@ feat(flash-card): add _get_user_context for safer user info retrieval
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献者认可规范 (Contributor Recognition Standards)
|
||||
|
||||
本项目使用 [All Contributors](https://allcontributors.org/) 规范来认可所有形式的贡献。
|
||||
|
||||
### 1. 如何添加贡献者 (How to Add)
|
||||
|
||||
在 GitHub 的 **Issue** 或 **Pull Request** 评论区发送以下指令,Bot 会自动创建 PR 更新 README:
|
||||
|
||||
```text
|
||||
@all-contributors please add @username for <contribution-type>
|
||||
```
|
||||
|
||||
### 2. 常用贡献类型 (Common Contribution Types)
|
||||
|
||||
| 类型 (Type) | 含义 (Meaning) | 图标 (Icon) |
|
||||
| :--- | :--- | :---: |
|
||||
| **`ideas`** | 提供想法、功能建议或改进思路 | 🤔 |
|
||||
| **`code`** | 编写并提交代码实现 | 💻 |
|
||||
| **`bug`** | 报告 Bug 或发现逻辑缺陷 | 🐛 |
|
||||
| **`doc`** | 改进文档、README 或注释 | 📖 |
|
||||
| **`translation`** | 提供多语言翻译支持 | 🌍 |
|
||||
| **`review`** | 进行代码审查 (Code Review) | 👀 |
|
||||
| **`design`** | 提供 UI/UX 设计或图标 | 🎨 |
|
||||
| **`question`** | 在讨论区回答用户问题 | 💬 |
|
||||
| **`tutorial`** | 编写教程或使用指南 | ✅ |
|
||||
|
||||
### 3. 核心区别:`ideas` vs `code`
|
||||
|
||||
- **`ideas`**: 贡献者提供了核心思路、逻辑优化方案或功能需求,但未直接编写代码。
|
||||
- **`code`**: 贡献者直接编写并提交了 Pull Request。
|
||||
- **组合使用**: 如果贡献者既提出了方案又完成了实现,应同时添加:`for ideas, code`。
|
||||
|
||||
### 4. 多次贡献处理 (Multiple Contributions)
|
||||
|
||||
All Contributors 支持勋章累加,无需担心重复添加:
|
||||
|
||||
- **累加勋章**: 如果贡献者已在列表中,再次发送指令指定新类型(如 `@all-contributors please add @user for doc`),Bot 会自动将新勋章追加到该用户头像下方。
|
||||
- **一次性添加**: 支持在单条指令中列出所有类型:`for code, doc, ideas`。
|
||||
- **手动修正**: 若需删除或修正勋章,需手动编辑 `.all-contributorsrc` 文件中的 `contributions` 数组。
|
||||
|
||||
---
|
||||
|
||||
## <20>📚 参考资源 (Reference Resources)
|
||||
|
||||
- [Action 插件模板 (英文)](plugins/actions/ACTION_PLUGIN_TEMPLATE.py)
|
||||
|
||||
14
.github/workflows/community-stats.yml
vendored
@@ -1,5 +1,5 @@
|
||||
# OpenWebUI 社区统计报告自动生成
|
||||
# 每小时自动获取并更新社区统计数据
|
||||
# 只在统计数据变化时 commit,避免频繁提交
|
||||
|
||||
name: Community Stats
|
||||
|
||||
@@ -32,23 +32,19 @@ jobs:
|
||||
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.zh.md docs/community-stats.md README.md README_CN.md || echo "changed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add docs/community-stats.zh.md docs/community-stats.md docs/community-stats.json README.md README_CN.md
|
||||
git commit -m "chore: update community stats $(date +'%Y-%m-%d')"
|
||||
git diff --staged --quiet || git commit -m "chore: update community stats $(date +'%Y-%m-%d')"
|
||||
git push
|
||||
|
||||
68
.github/workflows/publish_new_plugin.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Publish New Plugin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugin_dir:
|
||||
description: 'Plugin directory (e.g., plugins/actions/deep-dive)'
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run mode (preview only)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Validate plugin directory
|
||||
run: |
|
||||
if [ ! -d "${{ github.event.inputs.plugin_dir }}" ]; then
|
||||
echo "❌ Error: Directory '${{ github.event.inputs.plugin_dir }}' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Found plugin directory: ${{ github.event.inputs.plugin_dir }}"
|
||||
ls -la "${{ github.event.inputs.plugin_dir }}"
|
||||
|
||||
- name: Publish Plugin
|
||||
env:
|
||||
OPENWEBUI_API_KEY: ${{ secrets.OPENWEBUI_API_KEY }}
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
|
||||
echo "🔍 Dry run mode - previewing..."
|
||||
python scripts/publish_plugin.py --new "${{ github.event.inputs.plugin_dir }}" --dry-run
|
||||
else
|
||||
echo "🚀 Publishing plugin..."
|
||||
python scripts/publish_plugin.py --new "${{ github.event.inputs.plugin_dir }}"
|
||||
fi
|
||||
|
||||
- name: Commit changes (if ID was added)
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git add "${{ github.event.inputs.plugin_dir }}"
|
||||
git commit -m "feat: add openwebui_id to ${{ github.event.inputs.plugin_dir }}"
|
||||
git push
|
||||
echo "✅ Committed and pushed openwebui_id changes"
|
||||
fi
|
||||
5
.github/workflows/publish_plugin.yml
vendored
@@ -1,6 +1,11 @@
|
||||
name: Publish Plugins to OpenWebUI Market
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'plugins/**/*.py'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -1,87 +1,16 @@
|
||||
# 贡献指南 (Contributing Guide)
|
||||
# Contributing Guide
|
||||
|
||||
感谢你对 **OpenWebUI Extras** 感兴趣!我们非常欢迎社区贡献更多的插件、提示词和创意。
|
||||
Thank you for your interest in **OpenWebUI Extras**!
|
||||
|
||||
## 🤝 如何贡献
|
||||
## 🚀 How to Contribute
|
||||
|
||||
### 1. 分享提示词 (Prompts)
|
||||
1. **Fork** this repository.
|
||||
2. **Add/Modify** the plugin file in the `plugins/` directory.
|
||||
3. **Submit PR**: We will review and merge it.
|
||||
|
||||
如果你有一个好用的提示词:
|
||||
1. 在 `prompts/` 目录下找到合适的分类(如 `coding/`, `writing/`)。如果没有合适的,可以新建一个文件夹。
|
||||
2. 创建一个新的 `.md` 或 `.json` 文件。
|
||||
3. 提交 Pull Request (PR)。
|
||||
## 💡 Important
|
||||
|
||||
### 2. 开发插件 (Plugins)
|
||||
- Ensure your plugin includes complete metadata (title, author, version, description).
|
||||
- If updating an existing plugin, please **increment the version number** (e.g., `0.1.0` -> `0.1.1`) to trigger the auto-update.
|
||||
|
||||
如果你开发了一个新的 OpenWebUI 插件 (Function/Tool):
|
||||
1. 确保你的插件代码包含完整的元数据(Frontmatter):
|
||||
```python
|
||||
"""
|
||||
title: 插件名称
|
||||
author: 你的名字
|
||||
version: 0.1.0
|
||||
description: 简短描述插件的功能
|
||||
"""
|
||||
```
|
||||
2. 将插件文件放入 `plugins/` 目录下的合适位置:
|
||||
- `plugins/actions/`: 用于添加按钮或修改消息的 Action 插件。
|
||||
- `plugins/filters/`: 用于拦截请求或响应的 Filter 插件。
|
||||
- `plugins/pipes/`: 用于自定义模型或 API 的 Pipe 插件。
|
||||
- `plugins/tools/`: 用于 LLM 调用的 Tool 插件。
|
||||
3. 建议在 `docs/` 下添加一个简单的使用说明。
|
||||
|
||||
### 3. 改进文档
|
||||
|
||||
如果你发现文档有错误或可以改进的地方,直接提交 PR 即可。
|
||||
|
||||
## 🛠️ 开发规范
|
||||
|
||||
- **代码风格**:Python 代码请遵循 PEP 8 规范。
|
||||
- **注释**:关键逻辑请添加注释,方便他人理解。
|
||||
- **测试**:提交前请在本地 OpenWebUI 环境中测试通过。
|
||||
|
||||
## 📝 提交 PR
|
||||
|
||||
1. Fork 本仓库。
|
||||
2. 创建一个新的分支 (`git checkout -b feature/AmazingFeature`)。
|
||||
3. 提交你的修改 (`git commit -m 'Add some AmazingFeature'`)。
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)。
|
||||
5. 开启一个 Pull Request。
|
||||
|
||||
## 📦 版本更新与发布
|
||||
|
||||
当你更新插件时,请遵循以下流程:
|
||||
|
||||
### 1. 更新版本号
|
||||
|
||||
在插件文件的 docstring 中更新版本号(遵循[语义化版本](https://semver.org/lang/zh-CN/)):
|
||||
|
||||
```python
|
||||
"""
|
||||
title: 我的插件
|
||||
version: 0.2.0 # 更新此处
|
||||
...
|
||||
"""
|
||||
```
|
||||
|
||||
### 2. 更新更新日志
|
||||
|
||||
在 `CHANGELOG.md` 的 `[Unreleased]` 部分添加你的更改:
|
||||
|
||||
```markdown
|
||||
### Added / 新增
|
||||
- 新功能描述
|
||||
|
||||
### Fixed / 修复
|
||||
- Bug 修复描述
|
||||
```
|
||||
|
||||
### 3. 发布流程
|
||||
|
||||
维护者会通过以下方式发布新版本:
|
||||
- 手动触发 GitHub Actions 中的 "Plugin Release" 工作流
|
||||
- 或创建版本标签 (`v*`)
|
||||
|
||||
详细说明请参阅 [发布工作流文档](docs/release-workflow.zh.md)。
|
||||
|
||||
再次感谢你的贡献!🚀
|
||||
Thank you! 🚀
|
||||
|
||||
16
CONTRIBUTING_CN.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 **OpenWebUI Extras** 感兴趣!
|
||||
|
||||
## 🚀 贡献流程
|
||||
|
||||
1. **Fork** 本仓库。
|
||||
2. **修改/添加** `plugins/` 目录下的插件文件。
|
||||
3. **提交 PR**: 我们会尽快审核并合并。
|
||||
|
||||
## 💡 注意事项
|
||||
|
||||
- 请确保插件包含完整的元数据(title, author, version, description)。
|
||||
- 如果是更新已有插件,请记得**增加版本号**(如 `0.1.0` -> `0.1.1`),这样系统会自动同步更新。
|
||||
|
||||
再次感谢你的贡献!🚀
|
||||
58
README.md
@@ -1,4 +1,7 @@
|
||||
# OpenWebUI Extras
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
English | [中文](./README_CN.md)
|
||||
|
||||
@@ -7,26 +10,28 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu
|
||||
<!-- STATS_START -->
|
||||
## 📊 Community Stats
|
||||
|
||||
> 🕐 Auto-updated: 2026-01-08 00:11
|
||||
> 🕐 Auto-updated: 2026-01-13 22:10
|
||||
|
||||
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
|
||||
|:---:|:---:|:---:|:---:|
|
||||
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **49** | **63** | **18** |
|
||||
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **96** | **100** | **23** |
|
||||
|
||||
| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |
|
||||
|:---:|:---:|:---:|:---:|:---:|
|
||||
| **11** | **889** | **9358** | **55** | **48** |
|
||||
| **15** | **1298** | **14813** | **88** | **92** |
|
||||
|
||||
### 🔥 Top 6 Popular Plugins
|
||||
|
||||
| Rank | Plugin | Downloads | Views |
|
||||
|:---:|------|:---:|:---:|
|
||||
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 283 | 2441 |
|
||||
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 175 | 486 |
|
||||
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 118 | 1287 |
|
||||
| 4️⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 82 | 1528 |
|
||||
| 5️⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 80 | 1081 |
|
||||
| 6️⃣ | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 67 | 605 |
|
||||
> 🕐 Auto-updated: 2026-01-13 22:10
|
||||
|
||||
| Rank | Plugin | Version | Downloads | Views | Updated |
|
||||
|:---:|------|:---:|:---:|:---:|:---:|
|
||||
| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 0.9.1 | 412 | 3715 | 2026-01-07 |
|
||||
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 0.3.7 | 190 | 625 | 2026-01-07 |
|
||||
| 🥉 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 1.4.9 | 153 | 1685 | 2026-01-11 |
|
||||
| 4️⃣ | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 1.1.3 | 148 | 1643 | 2026-01-11 |
|
||||
| 5️⃣ | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 0.4.3 | 109 | 992 | 2026-01-07 |
|
||||
| 6️⃣ | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | 0.2.4 | 106 | 1956 | 2026-01-07 |
|
||||
|
||||
*See full stats in [Community Stats Report](./docs/community-stats.md)*
|
||||
<!-- STATS_END -->
|
||||
@@ -40,15 +45,18 @@ Located in the `plugins/` directory, containing Python-based enhancements:
|
||||
#### Actions
|
||||
- **Smart Mind Map** (`smart-mind-map`): Generates interactive mind maps from text.
|
||||
- **Smart Infographic** (`infographic`): Transforms text into professional infographics using AntV.
|
||||
- **Knowledge Card** (`knowledge-card`): Creates beautiful flashcards for learning.
|
||||
- **Flash Card** (`flash-card`): Quickly generates beautiful flashcards for learning.
|
||||
- **Deep Dive** (`deep-dive`): A comprehensive thinking lens that dives deep into any content.
|
||||
- **Export to Excel** (`export_to_excel`): Exports chat history to Excel files.
|
||||
- **Export to Word** (`export_to_docx`): Exports chat history to Word documents.
|
||||
- **Summary** (`summary`): Text summarization tool.
|
||||
|
||||
#### Filters
|
||||
- **Async Context Compression** (`async-context-compression`): Optimizes token usage via context compression.
|
||||
- **Context Enhancement** (`context_enhancement_filter`): Enhances chat context.
|
||||
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Companion filter for Gemini Manifold.
|
||||
- **Gemini Multimodal Filter** (`web_gemini_multimodel_filter`): Provides multimodal capabilities (PDF, Office, Video) for any model via Gemini.
|
||||
- **Markdown Normalizer** (`markdown_normalizer`): Fixes common Markdown formatting issues in LLM outputs.
|
||||
- **Multi-Model Context Merger** (`multi_model_context_merger`): Automatically merges and injects context from multiple model responses.
|
||||
|
||||
|
||||
#### Pipes
|
||||
@@ -104,3 +112,27 @@ If you have great prompts or plugins to share:
|
||||
3. Submit a Pull Request.
|
||||
|
||||
[Contributing](./CONTRIBUTING.md)
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rbb-dev"><img src="https://avatars.githubusercontent.com/u/37469229?v=4?s=100" width="100px;" alt="rbb-dev"/><br /><sub><b>rbb-dev</b></sub></a><br /><a href="#ideas-rbb-dev" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/Fu-Jie/awesome-openwebui/commits?author=rbb-dev" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://trade.xyz/?ref=BZ1RJRXWO"><img src="https://avatars.githubusercontent.com/u/7317522?v=4?s=100" width="100px;" alt="Raxxoor"/><br /><sub><b>Raxxoor</b></sub></a><br /><a href="https://github.com/Fu-Jie/awesome-openwebui/issues?q=author%3Adhaern" title="Bug reports">🐛</a> <a href="#ideas-dhaern" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/i-iooi-i"><img src="https://avatars.githubusercontent.com/u/1827701?v=4?s=100" width="100px;" alt="ZOLO"/><br /><sub><b>ZOLO</b></sub></a><br /><a href="https://github.com/Fu-Jie/awesome-openwebui/issues?q=author%3Ai-iooi-i" title="Bug reports">🐛</a> <a href="#ideas-i-iooi-i" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
33
README_CN.md
@@ -7,26 +7,28 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词
|
||||
<!-- STATS_START -->
|
||||
## 📊 社区统计
|
||||
|
||||
> 🕐 自动更新于 2026-01-08 00:11
|
||||
> 🕐 自动更新于 2026-01-13 22:10
|
||||
|
||||
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
|
||||
|:---:|:---:|:---:|:---:|
|
||||
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **49** | **63** | **18** |
|
||||
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **96** | **100** | **23** |
|
||||
|
||||
| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |
|
||||
|:---:|:---:|:---:|:---:|:---:|
|
||||
| **11** | **889** | **9358** | **55** | **48** |
|
||||
| **15** | **1298** | **14813** | **88** | **92** |
|
||||
|
||||
### 🔥 热门插件 Top 6
|
||||
|
||||
| 排名 | 插件 | 下载 | 浏览 |
|
||||
|:---:|------|:---:|:---:|
|
||||
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 283 | 2441 |
|
||||
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 175 | 486 |
|
||||
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 118 | 1287 |
|
||||
| 4️⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 82 | 1528 |
|
||||
| 5️⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 80 | 1081 |
|
||||
| 6️⃣ | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 67 | 605 |
|
||||
> 🕐 自动更新于 2026-01-13 22:10
|
||||
|
||||
| 排名 | 插件 | 版本 | 下载 | 浏览 | 更新日期 |
|
||||
|:---:|------|:---:|:---:|:---:|:---:|
|
||||
| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 0.9.1 | 412 | 3715 | 2026-01-07 |
|
||||
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 0.3.7 | 190 | 625 | 2026-01-07 |
|
||||
| 🥉 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 1.4.9 | 153 | 1685 | 2026-01-11 |
|
||||
| 4️⃣ | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 1.1.3 | 148 | 1643 | 2026-01-11 |
|
||||
| 5️⃣ | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | 0.4.3 | 109 | 992 | 2026-01-07 |
|
||||
| 6️⃣ | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | 0.2.4 | 106 | 1956 | 2026-01-07 |
|
||||
|
||||
*完整统计请查看 [社区统计报告](./docs/community-stats.zh.md)*
|
||||
<!-- STATS_END -->
|
||||
@@ -40,15 +42,18 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词
|
||||
#### Actions (交互增强)
|
||||
- **Smart Mind Map** (`smart-mind-map`): 智能分析文本并生成交互式思维导图。
|
||||
- **Smart Infographic** (`infographic`): 基于 AntV 的智能信息图生成工具。
|
||||
- **Knowledge Card** (`knowledge-card`): 快速生成精美的学习记忆卡片。
|
||||
- **Flash Card** (`flash-card`): 快速生成精美的学习记忆卡片。
|
||||
- **Deep Dive** (`deep-dive`): 深度思考透镜,从背景、逻辑、洞察到行动路径的全方位分析。
|
||||
- **Export to Excel** (`export_to_excel`): 将对话内容导出为 Excel 文件。
|
||||
- **Export to Word** (`export_to_docx`): 将对话内容导出为 Word 文档。
|
||||
- **Summary** (`summary`): 文本摘要生成工具。
|
||||
|
||||
#### Filters (消息处理)
|
||||
- **Async Context Compression** (`async-context-compression`): 异步上下文压缩,优化 Token 使用。
|
||||
- **Context Enhancement** (`context_enhancement_filter`): 上下文增强过滤器。
|
||||
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Gemini Manifold 配套增强。
|
||||
- **Gemini Multimodal Filter** (`web_gemini_multimodel_filter`): 为任意模型提供多模态能力(PDF、Office、视频等),支持智能路由和字幕精修。
|
||||
- **Markdown Normalizer** (`markdown_normalizer`): 修复 LLM 输出中常见的 Markdown 格式问题。
|
||||
- **Multi-Model Context Merger** (`multi_model_context_merger`): 自动合并并注入多模型回答的上下文。
|
||||
|
||||
#### Pipes (模型管道)
|
||||
- **Gemini Manifold** (`gemini_mainfold`): 集成 Gemini 模型的管道。
|
||||
@@ -105,4 +110,4 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词
|
||||
2. 将你的文件添加到对应的 `prompts/` 或 `plugins/` 目录。
|
||||
3. 提交 Pull Request。
|
||||
|
||||
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)
|
||||
[贡献指南](./CONTRIBUTING_CN.md) | [更新日志](./CHANGELOG.md)
|
||||
|
||||
@@ -1,191 +1,256 @@
|
||||
{
|
||||
"total_posts": 11,
|
||||
"total_downloads": 889,
|
||||
"total_views": 9358,
|
||||
"total_upvotes": 55,
|
||||
"total_downvotes": 1,
|
||||
"total_saves": 48,
|
||||
"total_comments": 15,
|
||||
"total_posts": 15,
|
||||
"total_downloads": 1298,
|
||||
"total_views": 14813,
|
||||
"total_upvotes": 88,
|
||||
"total_downvotes": 2,
|
||||
"total_saves": 92,
|
||||
"total_comments": 20,
|
||||
"by_type": {
|
||||
"action": 9,
|
||||
"filter": 2
|
||||
"filter": 1,
|
||||
"action": 13,
|
||||
"unknown": 1
|
||||
},
|
||||
"posts": [
|
||||
{
|
||||
"title": "Turn Any Text into Beautiful Mind Maps",
|
||||
"title": "Smart Mind Map",
|
||||
"slug": "turn_any_text_into_beautiful_mind_maps_3094c59a",
|
||||
"type": "action",
|
||||
"version": "0.9.1",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.",
|
||||
"downloads": 283,
|
||||
"views": 2441,
|
||||
"upvotes": 10,
|
||||
"saves": 15,
|
||||
"comments": 10,
|
||||
"downloads": 412,
|
||||
"views": 3715,
|
||||
"upvotes": 11,
|
||||
"saves": 24,
|
||||
"comments": 11,
|
||||
"created_at": "2025-12-30",
|
||||
"updated_at": "2026-01-06",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a"
|
||||
},
|
||||
{
|
||||
"title": "Export to Excel",
|
||||
"slug": "export_mulit_table_to_excel_244b8f9d",
|
||||
"type": "action",
|
||||
"version": "0.3.6",
|
||||
"version": "0.3.7",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
|
||||
"downloads": 175,
|
||||
"views": 486,
|
||||
"downloads": 190,
|
||||
"views": 625,
|
||||
"upvotes": 3,
|
||||
"saves": 3,
|
||||
"saves": 4,
|
||||
"comments": 0,
|
||||
"created_at": "2025-05-30",
|
||||
"updated_at": "2026-01-03",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d"
|
||||
},
|
||||
{
|
||||
"title": "📊 Smart Infographic (AntV)",
|
||||
"slug": "smart_infographic_ad6f0c7f",
|
||||
"type": "action",
|
||||
"version": "1.4.9",
|
||||
"author": "Fu-Jie",
|
||||
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
|
||||
"downloads": 153,
|
||||
"views": 1685,
|
||||
"upvotes": 8,
|
||||
"saves": 11,
|
||||
"comments": 2,
|
||||
"created_at": "2025-12-28",
|
||||
"updated_at": "2026-01-11",
|
||||
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
|
||||
},
|
||||
{
|
||||
"title": "Async Context Compression",
|
||||
"slug": "async_context_compression_b1655bc8",
|
||||
"type": "filter",
|
||||
"version": "1.1.0",
|
||||
"type": "action",
|
||||
"version": "1.1.3",
|
||||
"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": 118,
|
||||
"views": 1287,
|
||||
"upvotes": 5,
|
||||
"saves": 9,
|
||||
"description": "Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.",
|
||||
"downloads": 148,
|
||||
"views": 1643,
|
||||
"upvotes": 7,
|
||||
"saves": 12,
|
||||
"comments": 0,
|
||||
"created_at": "2025-11-08",
|
||||
"updated_at": "2025-12-31",
|
||||
"updated_at": "2026-01-11",
|
||||
"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": 82,
|
||||
"views": 1528,
|
||||
"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.4.0",
|
||||
"author": "jeff",
|
||||
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
|
||||
"downloads": 80,
|
||||
"views": 1081,
|
||||
"upvotes": 7,
|
||||
"saves": 8,
|
||||
"comments": 2,
|
||||
"created_at": "2025-12-28",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
|
||||
},
|
||||
{
|
||||
"title": "Export to Word (Enhanced Formatting)",
|
||||
"title": "Export to Word (Enhanced)",
|
||||
"slug": "export_to_word_enhanced_formatting_fca6a315",
|
||||
"type": "action",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Export the current conversation to a formatted Word doc with syntax highlighting, AI-generated titles, and perfect Markdown rendering (tables, quotes, lists).",
|
||||
"downloads": 67,
|
||||
"views": 605,
|
||||
"upvotes": 5,
|
||||
"saves": 4,
|
||||
"description": "Export current conversation from Markdown to Word (.docx) with Mermaid diagrams rendered client-side (Mermaid.js, SVG+PNG), LaTeX math, real hyperlinks, improved tables, syntax highlighting, and blockquote support.",
|
||||
"downloads": 109,
|
||||
"views": 992,
|
||||
"upvotes": 6,
|
||||
"saves": 10,
|
||||
"comments": 0,
|
||||
"created_at": "2026-01-03",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315"
|
||||
},
|
||||
{
|
||||
"title": "智能信息图",
|
||||
"slug": "智能信息图_e04a48ff",
|
||||
"title": "Flash Card",
|
||||
"slug": "flash_card_65a2ea8f",
|
||||
"type": "action",
|
||||
"version": "1.3.1",
|
||||
"author": "jeff",
|
||||
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
|
||||
"downloads": 33,
|
||||
"views": 426,
|
||||
"upvotes": 3,
|
||||
"saves": 0,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-28",
|
||||
"updated_at": "2025-12-29",
|
||||
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
|
||||
"version": "0.2.4",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
|
||||
"downloads": 106,
|
||||
"views": 1956,
|
||||
"upvotes": 8,
|
||||
"saves": 8,
|
||||
"comments": 2,
|
||||
"created_at": "2025-12-30",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/flash_card_65a2ea8f"
|
||||
},
|
||||
{
|
||||
"title": "导出为 Word-支持公式、流程图、表格和代码块",
|
||||
"title": "导出为 Word (增强版)",
|
||||
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
|
||||
"type": "action",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"author": "Fu-Jie",
|
||||
"description": "将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持中英文无乱码。",
|
||||
"downloads": 20,
|
||||
"views": 799,
|
||||
"upvotes": 7,
|
||||
"saves": 1,
|
||||
"description": "将对话导出为 Word (.docx),支持 Mermaid 图表 (客户端渲染 SVG+PNG)、LaTeX 数学公式、真实超链接、增强表格格式、代码高亮和引用块。",
|
||||
"downloads": 45,
|
||||
"views": 1094,
|
||||
"upvotes": 9,
|
||||
"saves": 3,
|
||||
"comments": 1,
|
||||
"created_at": "2026-01-04",
|
||||
"updated_at": "2026-01-05",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
|
||||
},
|
||||
{
|
||||
"title": "智能生成交互式思维导图,帮助用户可视化知识",
|
||||
"title": "📊 智能信息图 (AntV Infographic)",
|
||||
"slug": "智能信息图_e04a48ff",
|
||||
"type": "action",
|
||||
"version": "1.4.9",
|
||||
"author": "Fu-Jie",
|
||||
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
|
||||
"downloads": 37,
|
||||
"views": 546,
|
||||
"upvotes": 4,
|
||||
"saves": 0,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-28",
|
||||
"updated_at": "2026-01-11",
|
||||
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
|
||||
},
|
||||
{
|
||||
"title": "Deep Dive",
|
||||
"slug": "deep_dive_c0b846e4",
|
||||
"type": "action",
|
||||
"version": "1.0.0",
|
||||
"author": "Fu-Jie",
|
||||
"description": "A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.",
|
||||
"downloads": 37,
|
||||
"views": 408,
|
||||
"upvotes": 3,
|
||||
"saves": 4,
|
||||
"comments": 0,
|
||||
"created_at": "2026-01-08",
|
||||
"updated_at": "2026-01-08",
|
||||
"url": "https://openwebui.com/posts/deep_dive_c0b846e4"
|
||||
},
|
||||
{
|
||||
"title": "思维导图",
|
||||
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
|
||||
"type": "action",
|
||||
"version": "0.8.0",
|
||||
"author": "",
|
||||
"version": "0.9.1",
|
||||
"author": "Fu-Jie",
|
||||
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
|
||||
"downloads": 14,
|
||||
"views": 263,
|
||||
"downloads": 20,
|
||||
"views": 347,
|
||||
"upvotes": 2,
|
||||
"saves": 1,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-31",
|
||||
"updated_at": "2025-12-31",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b"
|
||||
},
|
||||
{
|
||||
"title": "闪记卡生成插件",
|
||||
"slug": "闪记卡生成插件_4a31eac3",
|
||||
"type": "action",
|
||||
"version": "0.2.2",
|
||||
"author": "Fu-Jie",
|
||||
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
|
||||
"downloads": 12,
|
||||
"views": 320,
|
||||
"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",
|
||||
"type": "action",
|
||||
"version": "1.1.3",
|
||||
"author": "Fu-Jie",
|
||||
"description": "在 LLM 响应完成后进行上下文摘要和压缩",
|
||||
"downloads": 5,
|
||||
"views": 122,
|
||||
"upvotes": 2,
|
||||
"description": "通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。",
|
||||
"downloads": 13,
|
||||
"views": 274,
|
||||
"upvotes": 4,
|
||||
"saves": 1,
|
||||
"comments": 0,
|
||||
"created_at": "2025-11-08",
|
||||
"updated_at": "2025-12-31",
|
||||
"updated_at": "2026-01-11",
|
||||
"url": "https://openwebui.com/posts/异步上下文压缩_5c0617cb"
|
||||
},
|
||||
{
|
||||
"title": "闪记卡 (Flash Card)",
|
||||
"slug": "闪记卡生成插件_4a31eac3",
|
||||
"type": "action",
|
||||
"version": "0.2.4",
|
||||
"author": "Fu-Jie",
|
||||
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
|
||||
"downloads": 12,
|
||||
"views": 383,
|
||||
"upvotes": 4,
|
||||
"saves": 1,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-30",
|
||||
"updated_at": "2026-01-07",
|
||||
"url": "https://openwebui.com/posts/闪记卡生成插件_4a31eac3"
|
||||
},
|
||||
{
|
||||
"title": "Markdown Normalizer",
|
||||
"slug": "markdown_normalizer_baaa8732",
|
||||
"type": "filter",
|
||||
"version": "1.1.0",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Fixes common Markdown formatting issues in LLM outputs, such as broken code blocks, LaTeX formulas, and list formatting.",
|
||||
"downloads": 11,
|
||||
"views": 406,
|
||||
"upvotes": 6,
|
||||
"saves": 4,
|
||||
"comments": 2,
|
||||
"created_at": "2026-01-12",
|
||||
"updated_at": "2026-01-12",
|
||||
"url": "https://openwebui.com/posts/markdown_normalizer_baaa8732"
|
||||
},
|
||||
{
|
||||
"title": "精读",
|
||||
"slug": "精读_99830b0f",
|
||||
"type": "action",
|
||||
"version": "1.0.0",
|
||||
"author": "Fu-Jie",
|
||||
"description": "全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。",
|
||||
"downloads": 5,
|
||||
"views": 150,
|
||||
"upvotes": 2,
|
||||
"saves": 2,
|
||||
"comments": 0,
|
||||
"created_at": "2026-01-08",
|
||||
"updated_at": "2026-01-08",
|
||||
"url": "https://openwebui.com/posts/精读_99830b0f"
|
||||
},
|
||||
{
|
||||
"title": " 🛠️ Debug Open WebUI Plugins in Your Browser",
|
||||
"slug": "debug_open_webui_plugins_in_your_browser_81bf7960",
|
||||
"type": "unknown",
|
||||
"version": "",
|
||||
"author": "",
|
||||
"description": "",
|
||||
"downloads": 0,
|
||||
"views": 589,
|
||||
"upvotes": 11,
|
||||
"saves": 7,
|
||||
"comments": 2,
|
||||
"created_at": "2026-01-10",
|
||||
"updated_at": "2026-01-10",
|
||||
"url": "https://openwebui.com/posts/debug_open_webui_plugins_in_your_browser_81bf7960"
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
@@ -193,11 +258,11 @@
|
||||
"name": "Fu-Jie",
|
||||
"profile_url": "https://openwebui.com/u/Fu-Jie",
|
||||
"profile_image": "https://community.s3.openwebui.com/uploads/users/b15d1348-4347-42b4-b815-e053342d6cb0/profile_d9510745-4bd4-4f8f-a997-4a21847d9300.webp",
|
||||
"followers": 49,
|
||||
"followers": 96,
|
||||
"following": 2,
|
||||
"total_points": 63,
|
||||
"post_points": 54,
|
||||
"comment_points": 9,
|
||||
"contributions": 18
|
||||
"total_points": 100,
|
||||
"post_points": 86,
|
||||
"comment_points": 14,
|
||||
"contributions": 23
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,40 @@
|
||||
# 📊 OpenWebUI Community Stats Report
|
||||
|
||||
> 📅 Updated: 2026-01-08 00:11
|
||||
> 📅 Updated: 2026-01-13 22:10
|
||||
|
||||
## 📈 Overview
|
||||
|
||||
| Metric | Value |
|
||||
|------|------|
|
||||
| 📝 Total Posts | 11 |
|
||||
| ⬇️ Total Downloads | 889 |
|
||||
| 👁️ Total Views | 9358 |
|
||||
| 👍 Total Upvotes | 55 |
|
||||
| 💾 Total Saves | 48 |
|
||||
| 💬 Total Comments | 15 |
|
||||
| 📝 Total Posts | 15 |
|
||||
| ⬇️ Total Downloads | 1298 |
|
||||
| 👁️ Total Views | 14813 |
|
||||
| 👍 Total Upvotes | 88 |
|
||||
| 💾 Total Saves | 92 |
|
||||
| 💬 Total Comments | 20 |
|
||||
|
||||
## 📂 By Type
|
||||
|
||||
- **action**: 9
|
||||
- **filter**: 2
|
||||
- **filter**: 1
|
||||
- **action**: 13
|
||||
- **unknown**: 1
|
||||
|
||||
## 📋 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.9.1 | 283 | 2441 | 10 | 15 | 2026-01-06 |
|
||||
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 175 | 486 | 3 | 3 | 2026-01-03 |
|
||||
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 118 | 1287 | 5 | 9 | 2025-12-31 |
|
||||
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 82 | 1528 | 8 | 5 | 2026-01-03 |
|
||||
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.0 | 80 | 1081 | 7 | 8 | 2026-01-07 |
|
||||
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.2 | 67 | 605 | 5 | 4 | 2026-01-07 |
|
||||
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 426 | 3 | 0 | 2025-12-29 |
|
||||
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 20 | 799 | 7 | 1 | 2026-01-05 |
|
||||
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 263 | 2 | 1 | 2025-12-31 |
|
||||
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 320 | 3 | 1 | 2025-12-31 |
|
||||
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 122 | 2 | 1 | 2025-12-31 |
|
||||
| 1 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 412 | 3715 | 11 | 24 | 2026-01-07 |
|
||||
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.7 | 190 | 625 | 3 | 4 | 2026-01-07 |
|
||||
| 3 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.9 | 153 | 1685 | 8 | 11 | 2026-01-11 |
|
||||
| 4 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | action | 1.1.3 | 148 | 1643 | 7 | 12 | 2026-01-11 |
|
||||
| 5 | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.3 | 109 | 992 | 6 | 10 | 2026-01-07 |
|
||||
| 6 | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 106 | 1956 | 8 | 8 | 2026-01-07 |
|
||||
| 7 | [导出为 Word (增强版)](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.3 | 45 | 1094 | 9 | 3 | 2026-01-07 |
|
||||
| 8 | [📊 智能信息图 (AntV Infographic)](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.4.9 | 37 | 546 | 4 | 0 | 2026-01-11 |
|
||||
| 9 | [Deep Dive](https://openwebui.com/posts/deep_dive_c0b846e4) | action | 1.0.0 | 37 | 408 | 3 | 4 | 2026-01-08 |
|
||||
| 10 | [思维导图](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.9.1 | 20 | 347 | 2 | 1 | 2026-01-07 |
|
||||
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | action | 1.1.3 | 13 | 274 | 4 | 1 | 2026-01-11 |
|
||||
| 12 | [闪记卡 (Flash Card)](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.4 | 12 | 383 | 4 | 1 | 2026-01-07 |
|
||||
| 13 | [Markdown Normalizer](https://openwebui.com/posts/markdown_normalizer_baaa8732) | filter | 1.1.0 | 11 | 406 | 6 | 4 | 2026-01-12 |
|
||||
| 14 | [精读](https://openwebui.com/posts/精读_99830b0f) | action | 1.0.0 | 5 | 150 | 2 | 2 | 2026-01-08 |
|
||||
| 15 | [ 🛠️ Debug Open WebUI Plugins in Your Browser](https://openwebui.com/posts/debug_open_webui_plugins_in_your_browser_81bf7960) | unknown | | 0 | 589 | 11 | 7 | 2026-01-10 |
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
# 📊 OpenWebUI 社区统计报告
|
||||
|
||||
> 📅 更新时间: 2026-01-08 00:11
|
||||
> 📅 更新时间: 2026-01-13 22:10
|
||||
|
||||
## 📈 总览
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 📝 发布数量 | 11 |
|
||||
| ⬇️ 总下载量 | 889 |
|
||||
| 👁️ 总浏览量 | 9358 |
|
||||
| 👍 总点赞数 | 55 |
|
||||
| 💾 总收藏数 | 48 |
|
||||
| 💬 总评论数 | 15 |
|
||||
| 📝 发布数量 | 15 |
|
||||
| ⬇️ 总下载量 | 1298 |
|
||||
| 👁️ 总浏览量 | 14813 |
|
||||
| 👍 总点赞数 | 88 |
|
||||
| 💾 总收藏数 | 92 |
|
||||
| 💬 总评论数 | 20 |
|
||||
|
||||
## 📂 按类型分类
|
||||
|
||||
- **action**: 9
|
||||
- **filter**: 2
|
||||
- **filter**: 1
|
||||
- **action**: 13
|
||||
- **unknown**: 1
|
||||
|
||||
## 📋 发布列表
|
||||
|
||||
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
|
||||
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 283 | 2441 | 10 | 15 | 2026-01-06 |
|
||||
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 175 | 486 | 3 | 3 | 2026-01-03 |
|
||||
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 118 | 1287 | 5 | 9 | 2025-12-31 |
|
||||
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 82 | 1528 | 8 | 5 | 2026-01-03 |
|
||||
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.0 | 80 | 1081 | 7 | 8 | 2026-01-07 |
|
||||
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.2 | 67 | 605 | 5 | 4 | 2026-01-07 |
|
||||
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 426 | 3 | 0 | 2025-12-29 |
|
||||
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 20 | 799 | 7 | 1 | 2026-01-05 |
|
||||
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 263 | 2 | 1 | 2025-12-31 |
|
||||
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 320 | 3 | 1 | 2025-12-31 |
|
||||
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 122 | 2 | 1 | 2025-12-31 |
|
||||
| 1 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.9.1 | 412 | 3715 | 11 | 24 | 2026-01-07 |
|
||||
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.7 | 190 | 625 | 3 | 4 | 2026-01-07 |
|
||||
| 3 | [📊 Smart Infographic (AntV)](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.4.9 | 153 | 1685 | 8 | 11 | 2026-01-11 |
|
||||
| 4 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | action | 1.1.3 | 148 | 1643 | 7 | 12 | 2026-01-11 |
|
||||
| 5 | [Export to Word (Enhanced)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.3 | 109 | 992 | 6 | 10 | 2026-01-07 |
|
||||
| 6 | [Flash Card](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 106 | 1956 | 8 | 8 | 2026-01-07 |
|
||||
| 7 | [导出为 Word (增强版)](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.3 | 45 | 1094 | 9 | 3 | 2026-01-07 |
|
||||
| 8 | [📊 智能信息图 (AntV Infographic)](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.4.9 | 37 | 546 | 4 | 0 | 2026-01-11 |
|
||||
| 9 | [Deep Dive](https://openwebui.com/posts/deep_dive_c0b846e4) | action | 1.0.0 | 37 | 408 | 3 | 4 | 2026-01-08 |
|
||||
| 10 | [思维导图](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.9.1 | 20 | 347 | 2 | 1 | 2026-01-07 |
|
||||
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | action | 1.1.3 | 13 | 274 | 4 | 1 | 2026-01-11 |
|
||||
| 12 | [闪记卡 (Flash Card)](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.4 | 12 | 383 | 4 | 1 | 2026-01-07 |
|
||||
| 13 | [Markdown Normalizer](https://openwebui.com/posts/markdown_normalizer_baaa8732) | filter | 1.1.0 | 11 | 406 | 6 | 4 | 2026-01-12 |
|
||||
| 14 | [精读](https://openwebui.com/posts/精读_99830b0f) | action | 1.0.0 | 5 | 150 | 2 | 2 | 2026-01-08 |
|
||||
| 15 | [ 🛠️ Debug Open WebUI Plugins in Your Browser](https://openwebui.com/posts/debug_open_webui_plugins_in_your_browser_81bf7960) | unknown | | 0 | 589 | 11 | 7 | 2026-01-10 |
|
||||
|
||||
150
docs/development/frontend-console-debugging.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 🛠️ Debugging Python Plugins with Frontend Console
|
||||
|
||||
When developing plugins for Open WebUI, debugging can be challenging. Standard `print()` statements or server-side logging might not always be accessible, especially in hosted environments or when you want to see the data flow in real-time alongside the UI interactions.
|
||||
|
||||
This guide introduces a powerful technique: **Frontend Console Debugging**. By injecting JavaScript from your Python plugin, you can print structured logs directly to the browser's Developer Tools console (F12).
|
||||
|
||||
## Why Frontend Debugging?
|
||||
|
||||
* **Real-time Feedback**: See logs immediately as actions happen in the browser.
|
||||
* **Rich Objects**: Inspect complex JSON objects (like `body` or `messages`) interactively, rather than reading massive text dumps.
|
||||
* **No Server Access Needed**: Debug issues even if you don't have SSH/Console access to the backend server.
|
||||
* **Clean Output**: Group logs using `console.group()` to keep your console organized.
|
||||
|
||||
## The Core Mechanism
|
||||
|
||||
Open WebUI plugins (both Actions and Filters) support an event system. We can leverage the `__event_call__` (or sometimes `__event_emitter__`) to send a special event of type `execute`. This tells the frontend to run the provided JavaScript code.
|
||||
|
||||
### The Helper Method
|
||||
|
||||
To make this easy to use, we recommend adding a helper method `_emit_debug_log` to your plugin class.
|
||||
|
||||
```python
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
async def _emit_debug_log(
|
||||
self,
|
||||
__event_call__,
|
||||
title: str,
|
||||
data: dict
|
||||
):
|
||||
"""
|
||||
Emit debug log to browser console via JS execution.
|
||||
|
||||
Args:
|
||||
__event_call__: The event callable passed to action/outlet.
|
||||
title: A title for the log group.
|
||||
data: A dictionary of data to log.
|
||||
"""
|
||||
# 1. Check if debugging is enabled (recommended)
|
||||
if not getattr(self.valves, "show_debug_log", True) or not __event_call__:
|
||||
return
|
||||
|
||||
try:
|
||||
# 2. Construct the JavaScript code
|
||||
# We use an async IIFE (Immediately Invoked Function Expression)
|
||||
# to ensure a clean scope and support await if needed.
|
||||
js_code = f"""
|
||||
(async function() {{
|
||||
console.group("🛠️ Plugin Debug: {title}");
|
||||
console.log({json.dumps(data, ensure_ascii=False)});
|
||||
console.groupEnd();
|
||||
}})();
|
||||
"""
|
||||
|
||||
# 3. Send the execute event
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error emitting debug log: {e}")
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add a Valve for Control
|
||||
|
||||
It's best practice to make debugging optional so it doesn't clutter the console for normal users.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
show_debug_log: bool = Field(
|
||||
default=False,
|
||||
description="Print debug logs to browser console (F12)"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
```
|
||||
|
||||
### 2. Inject `__event_call__`
|
||||
|
||||
Ensure your `action` (for Actions) or `outlet` (for Filters) method accepts `__event_call__`.
|
||||
|
||||
**For Filters (`outlet`):**
|
||||
|
||||
```python
|
||||
async def outlet(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[dict] = None,
|
||||
__event_call__=None, # <--- Add this
|
||||
__metadata__: Optional[dict] = None,
|
||||
) -> dict:
|
||||
```
|
||||
|
||||
**For Actions (`action`):**
|
||||
|
||||
```python
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_call__=None, # <--- Add this
|
||||
__request__=None,
|
||||
):
|
||||
```
|
||||
|
||||
### 3. Call the Helper
|
||||
|
||||
Now you can log anything, anywhere in your logic!
|
||||
|
||||
```python
|
||||
# Inside your logic...
|
||||
new_content = self.process_content(content)
|
||||
|
||||
# Log the before and after
|
||||
await self._emit_debug_log(
|
||||
__event_call__,
|
||||
"Content Normalization",
|
||||
{
|
||||
"original": content,
|
||||
"processed": new_content,
|
||||
"changes": diff_list
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `json.dumps`**: Always serialize your Python dictionaries to JSON strings before embedding them in the f-string. This handles escaping quotes and special characters correctly.
|
||||
2. **Async IIFE**: Wrapping your JS in `(async function() { ... })();` is safer than raw code. It prevents variable collisions with other scripts and allows using `await` inside your debug script if you ever need to check DOM elements.
|
||||
3. **Check for None**: Always check if `__event_call__` is not None before using it, as it might not be available in all contexts (e.g., when running tests or in older Open WebUI versions).
|
||||
|
||||
## Example Output
|
||||
|
||||
When enabled, your browser console will show:
|
||||
|
||||
```text
|
||||
> 🛠️ Plugin Debug: Content Normalization
|
||||
> {original: "...", processed: "...", changes: [...]}
|
||||
```
|
||||
|
||||
You can expand the object to inspect every detail of your data. Happy debugging!
|
||||
64
docs/development/mermaid-syntax-standards.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Mermaid Syntax Standards & Best Practices
|
||||
|
||||
This document summarizes the official syntax standards for Mermaid flowcharts, focusing on node labels, quoting rules, and special character handling. It serves as a reference for the `markdown_normalizer` plugin logic.
|
||||
|
||||
## 1. Node Shapes & Syntax
|
||||
|
||||
Mermaid supports various node shapes defined by specific wrapping characters.
|
||||
|
||||
| Shape | Syntax | Example |
|
||||
| :--- | :--- | :--- |
|
||||
| **Rectangle** (Default) | `id[Label]` | `A[Start]` |
|
||||
| **Rounded** | `id(Label)` | `B(Process)` |
|
||||
| **Stadium** (Pill) | `id([Label])` | `C([End])` |
|
||||
| **Subroutine** | `id[[Label]]` | `D[[Subroutine]]` |
|
||||
| **Cylinder** (Database) | `id[(Label)]` | `E[(Database)]` |
|
||||
| **Circle** | `id((Label))` | `F((Point))` |
|
||||
| **Double Circle** | `id(((Label)))` | `G(((Endpoint)))` |
|
||||
| **Asymmetric** | `id>Label]` | `H>Flag]` |
|
||||
| **Rhombus** (Decision) | `id{Label}` | `I{Decision}` |
|
||||
| **Hexagon** | `id{{Label}}` | `J{{Prepare}}` |
|
||||
| **Parallelogram** | `id[/Label/]` | `K[/Input/]` |
|
||||
| **Parallelogram Alt** | `id[\Label\]` | `L[\Output\]` |
|
||||
| **Trapezoid** | `id[/Label\]` | `M[/Trap/]` |
|
||||
| **Trapezoid Alt** | `id[\Label/]` | `N[\TrapAlt/]` |
|
||||
|
||||
## 2. Quoting Rules (Critical)
|
||||
|
||||
### Why Quote?
|
||||
Quoting node labels is **highly recommended** and sometimes **mandatory** to prevent syntax errors.
|
||||
|
||||
### Mandatory Quoting Scenarios
|
||||
You **MUST** enclose labels in double quotes `"` if they contain:
|
||||
1. **Special Characters**: `()`, `[]`, `{}`, `;`, `"`, etc.
|
||||
2. **Keywords**: Words like `end`, `subgraph`, etc., if used in specific contexts.
|
||||
3. **Unicode/Emoji**: While often supported without quotes, quoting ensures consistent rendering across different environments.
|
||||
4. **Markdown**: If you want to use Markdown formatting (bold, italic) inside a label.
|
||||
|
||||
### Best Practice: Always Quote
|
||||
To ensure robustness, especially when processing LLM-generated content which may contain unpredictable characters, **always enclosing labels in double quotes is the safest strategy**.
|
||||
|
||||
**Examples:**
|
||||
* ❌ Risky: `id(Start: 15:00)` (Colon might be interpreted as style separator)
|
||||
* ✅ Safe: `id("Start: 15:00")`
|
||||
* ❌ Broken: `id(Func(x))` (Nested parentheses break parsing)
|
||||
* ✅ Safe: `id("Func(x)")`
|
||||
|
||||
## 3. Escape Characters
|
||||
|
||||
Inside a quoted string:
|
||||
* Double quotes `"` must be escaped as `\"`.
|
||||
* HTML entities (e.g., `#35;` for `#`) can be used.
|
||||
|
||||
## 4. Plugin Logic Verification
|
||||
|
||||
The `markdown_normalizer` plugin implements the following logic:
|
||||
|
||||
1. **Detection**: Identifies Mermaid node definitions using a comprehensive regex covering all shapes above.
|
||||
2. **Normalization**:
|
||||
* Checks if the label is already quoted.
|
||||
* If **NOT quoted**, it wraps the label in double quotes `""`.
|
||||
* Escapes any existing double quotes inside the label (`"` -> `\"`).
|
||||
3. **Shape Preservation**: The regex captures the specific opening and closing delimiters (e.g., `((` and `))`) to ensure the node shape is strictly preserved during normalization.
|
||||
|
||||
**Conclusion**: The plugin's behavior of automatically adding quotes to unquoted labels is **fully aligned with Mermaid's official best practices** for robustness and error prevention.
|
||||
111
docs/plugins/actions/deep-dive.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Deep Dive
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v1.0.0</span>
|
||||
|
||||
A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Deep Dive plugin transforms how you understand complex content by guiding you through a structured thinking process. Rather than just summarizing, it deconstructs content across four phases:
|
||||
|
||||
- **🔍 The Context (What?)**: Panoramic view of the situation and background
|
||||
- **🧠 The Logic (Why?)**: Deconstruction of reasoning and mental models
|
||||
- **💎 The Insight (So What?)**: Non-obvious value and hidden implications
|
||||
- **🚀 The Path (Now What?)**: Specific, prioritized strategic actions
|
||||
|
||||
## Features
|
||||
|
||||
- :material-brain: **Thinking Chain**: Complete structured analysis process
|
||||
- :material-eye: **Deep Understanding**: Reveals hidden assumptions and blind spots
|
||||
- :material-lightbulb-on: **Insight Extraction**: Finds the "Aha!" moments
|
||||
- :material-rocket-launch: **Action Oriented**: Translates understanding into actionable steps
|
||||
- :material-theme-light-dark: **Theme Adaptive**: Auto-adapts to OpenWebUI light/dark theme
|
||||
- :material-translate: **Multi-language**: Outputs in user's preferred language
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the plugin file: [`deep_dive.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive)
|
||||
2. Upload to OpenWebUI: **Admin Panel** → **Settings** → **Functions**
|
||||
3. Enable the plugin
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Provide any long text, article, or meeting notes in the chat
|
||||
2. Click the **Deep Dive** button in the message action bar
|
||||
3. Follow the visual timeline from Context → Logic → Insight → Path
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `SHOW_STATUS` | boolean | `true` | Show status updates during processing |
|
||||
| `MODEL_ID` | string | `""` | LLM model for analysis (empty = current model) |
|
||||
| `MIN_TEXT_LENGTH` | integer | `200` | Minimum text length for analysis |
|
||||
| `CLEAR_PREVIOUS_HTML` | boolean | `true` | Clear previous plugin results |
|
||||
| `MESSAGE_COUNT` | integer | `1` | Number of recent messages to analyze |
|
||||
|
||||
---
|
||||
|
||||
## Theme Support
|
||||
|
||||
Deep Dive automatically adapts to OpenWebUI's light/dark theme:
|
||||
|
||||
- Detects theme from parent document `<meta name="theme-color">` tag
|
||||
- Falls back to `html/body` class or `data-theme` attribute
|
||||
- Uses system preference `prefers-color-scheme: dark` as last resort
|
||||
|
||||
!!! tip "For Best Results"
|
||||
Enable **iframe Sandbox Allow Same Origin** in OpenWebUI:
|
||||
**Settings** → **Interface** → **Artifacts** → Check **iframe Sandbox Allow Same Origin**
|
||||
|
||||
---
|
||||
|
||||
## Example Output
|
||||
|
||||
The plugin generates a beautiful structured timeline:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🌊 Deep Dive Analysis │
|
||||
│ 👤 User 📅 Date 📊 Word count │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔍 Phase 01: The Context │
|
||||
│ [High-level panoramic view] │
|
||||
│ │
|
||||
│ 🧠 Phase 02: The Logic │
|
||||
│ • Reasoning structure... │
|
||||
│ • Hidden assumptions... │
|
||||
│ │
|
||||
│ 💎 Phase 03: The Insight │
|
||||
│ • Non-obvious value... │
|
||||
│ • Blind spots revealed... │
|
||||
│ │
|
||||
│ 🚀 Phase 04: The Path │
|
||||
│ ▸ Priority Action 1... │
|
||||
│ ▸ Priority Action 2... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
!!! note "Prerequisites"
|
||||
- OpenWebUI v0.3.0 or later
|
||||
- Uses the active LLM model for analysis
|
||||
- Requires `markdown` Python package
|
||||
|
||||
---
|
||||
|
||||
## Source Code
|
||||
|
||||
[:fontawesome-brands-github: View on GitHub](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive){ .md-button }
|
||||
111
docs/plugins/actions/deep-dive.zh.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 精读 (Deep Dive)
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v1.0.0</span>
|
||||
|
||||
全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
精读插件改变了您理解复杂内容的方式,通过结构化的思维过程引导您进行深度分析。它不仅仅是摘要,而是从四个阶段解构内容:
|
||||
|
||||
- **🔍 全景 (The Context)**: 情境与背景的高层级全景视图
|
||||
- **🧠 脉络 (The Logic)**: 解构底层推理逻辑与思维模型
|
||||
- **💎 洞察 (The Insight)**: 提取非显性价值与隐藏含义
|
||||
- **🚀 路径 (The Path)**: 具体的、按优先级排列的战略行动
|
||||
|
||||
## 功能特性
|
||||
|
||||
- :material-brain: **思维链**: 完整的结构化分析过程
|
||||
- :material-eye: **深度理解**: 揭示隐藏的假设和思维盲点
|
||||
- :material-lightbulb-on: **洞察提取**: 发现"原来如此"的时刻
|
||||
- :material-rocket-launch: **行动导向**: 将深度理解转化为可执行步骤
|
||||
- :material-theme-light-dark: **主题自适应**: 自动适配 OpenWebUI 深色/浅色主题
|
||||
- :material-translate: **多语言**: 以用户偏好语言输出
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
1. 下载插件文件: [`deep_dive_cn.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive)
|
||||
2. 上传到 OpenWebUI: **管理面板** → **设置** → **Functions**
|
||||
3. 启用插件
|
||||
|
||||
---
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 在聊天中提供任何长文本、文章或会议记录
|
||||
2. 点击消息操作栏中的 **精读** 按钮
|
||||
3. 沿着视觉时间轴从"全景"探索到"路径"
|
||||
|
||||
---
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `SHOW_STATUS` | boolean | `true` | 处理过程中是否显示状态更新 |
|
||||
| `MODEL_ID` | string | `""` | 用于分析的 LLM 模型(空 = 当前模型) |
|
||||
| `MIN_TEXT_LENGTH` | integer | `200` | 分析所需的最小文本长度 |
|
||||
| `CLEAR_PREVIOUS_HTML` | boolean | `true` | 是否清除之前的插件结果 |
|
||||
| `MESSAGE_COUNT` | integer | `1` | 要分析的最近消息数量 |
|
||||
|
||||
---
|
||||
|
||||
## 主题支持
|
||||
|
||||
精读插件自动适配 OpenWebUI 的深色/浅色主题:
|
||||
|
||||
- 从父文档 `<meta name="theme-color">` 标签检测主题
|
||||
- 回退到 `html/body` 的 class 或 `data-theme` 属性
|
||||
- 最后使用系统偏好 `prefers-color-scheme: dark`
|
||||
|
||||
!!! tip "最佳效果"
|
||||
请在 OpenWebUI 中启用 **iframe Sandbox Allow Same Origin**:
|
||||
**设置** → **界面** → **Artifacts** → 勾选 **iframe Sandbox Allow Same Origin**
|
||||
|
||||
---
|
||||
|
||||
## 输出示例
|
||||
|
||||
插件生成精美的结构化时间轴:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📖 精读分析报告 │
|
||||
│ 👤 用户 📅 日期 📊 字数 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔍 阶段 01: 全景 (The Context) │
|
||||
│ [高层级全景视图内容] │
|
||||
│ │
|
||||
│ 🧠 阶段 02: 脉络 (The Logic) │
|
||||
│ • 推理结构分析... │
|
||||
│ • 隐藏假设识别... │
|
||||
│ │
|
||||
│ 💎 阶段 03: 洞察 (The Insight) │
|
||||
│ • 非显性价值提取... │
|
||||
│ • 思维盲点揭示... │
|
||||
│ │
|
||||
│ 🚀 阶段 04: 路径 (The Path) │
|
||||
│ ▸ 优先级行动 1... │
|
||||
│ ▸ 优先级行动 2... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 系统要求
|
||||
|
||||
!!! note "前提条件"
|
||||
- OpenWebUI v0.3.0 或更高版本
|
||||
- 使用当前活跃的 LLM 模型进行分析
|
||||
- 需要 `markdown` Python 包
|
||||
|
||||
---
|
||||
|
||||
## 源代码
|
||||
|
||||
[:fontawesome-brands-github: 在 GitHub 上查看](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/deep-dive){ .md-button }
|
||||
@@ -33,7 +33,7 @@ Actions are interactive plugins that:
|
||||
|
||||
Transform text into professional infographics using AntV visualization engine with various templates.
|
||||
|
||||
**Version:** 1.4.1
|
||||
**Version:** 1.4.9
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](smart-infographic.md)
|
||||
|
||||
@@ -67,15 +67,15 @@ Actions are interactive plugins that:
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](export-to-word.md)
|
||||
|
||||
- :material-text-box-search:{ .lg .middle } **Summary**
|
||||
- :material-brain:{ .lg .middle } **Deep Dive**
|
||||
|
||||
---
|
||||
|
||||
Generate concise summaries of long text content with key points extraction.
|
||||
A comprehensive thinking lens that dives deep into any content - Context → Logic → Insight → Path. Supports theme auto-adaptation.
|
||||
|
||||
**Version:** 0.1.0
|
||||
**Version:** 1.0.0
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](summary.md)
|
||||
[:octicons-arrow-right-24: Documentation](deep-dive.md)
|
||||
|
||||
- :material-image-text:{ .lg .middle } **Infographic to Markdown**
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Actions 是交互式插件,能够:
|
||||
|
||||
使用 AntV 可视化引擎,将文本转成专业的信息图。
|
||||
|
||||
**版本:** 1.4.1
|
||||
**版本:** 1.4.9
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](smart-infographic.md)
|
||||
|
||||
@@ -67,15 +67,15 @@ Actions 是交互式插件,能够:
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](export-to-word.md)
|
||||
|
||||
- :material-text-box-search:{ .lg .middle } **Summary**
|
||||
- :material-brain:{ .lg .middle } **精读 (Deep Dive)**
|
||||
|
||||
---
|
||||
|
||||
对长文本进行精简总结,提取要点。
|
||||
全方位的思维透镜 —— 全景 → 脉络 → 洞察 → 路径。支持主题自适应。
|
||||
|
||||
**版本:** 0.1.0
|
||||
**版本:** 1.0.0
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](summary.md)
|
||||
[:octicons-arrow-right-24: 查看文档](deep-dive.zh.md)
|
||||
|
||||
- :material-image-text:{ .lg .middle } **信息图转 Markdown**
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Smart Infographic
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v1.4.0</span>
|
||||
<span class="version-badge">v1.4.9</span>
|
||||
|
||||
An AntV Infographic engine powered plugin that transforms long text into professional, beautiful infographics with a single click.
|
||||
|
||||
@@ -14,8 +14,8 @@ The Smart Infographic plugin uses AI to analyze text content and generate profes
|
||||
## Features
|
||||
|
||||
- :material-robot: **AI-Powered Transformation**: Automatically analyzes text logic, extracts key points, and generates structured charts
|
||||
- :material-palette: **Professional Templates**: Includes various AntV official templates: Lists, Trees, Mindmaps, Comparison Tables, Flowcharts, and Statistical Charts
|
||||
- :material-magnify: **Auto-Icon Matching**: Built-in logic to search and match the most relevant Material Design Icons based on content
|
||||
- :material-palette: **70+ Professional Templates**: Includes various AntV official templates: Lists, Trees, Roadmaps, Timelines, Comparison Tables, SWOT, Quadrants, and Statistical Charts
|
||||
- :material-magnify: **Auto-Icon Matching**: Built-in logic to search and match the most relevant icons (Iconify) and illustrations (unDraw)
|
||||
- :material-download: **Multi-Format Export**: Download your infographics as **SVG**, **PNG**, or **Standalone HTML** file
|
||||
- :material-theme-light-dark: **Theme Support**: Supports Dark/Light modes, auto-adapts theme colors
|
||||
- :material-cellphone-link: **Responsive Design**: Generated charts look great on both desktop and mobile devices
|
||||
@@ -37,10 +37,11 @@ The Smart Infographic plugin uses AI to analyze text content and generate profes
|
||||
|
||||
| Category | Template Name | Use Case |
|
||||
|:---------|:--------------|:---------|
|
||||
| **Lists & Hierarchy** | `list-grid`, `tree-vertical`, `mindmap` | Features, Org Charts, Brainstorming |
|
||||
| **Sequence & Relation** | `sequence-roadmap`, `relation-circle` | Roadmaps, Circular Flows, Steps |
|
||||
| **Comparison & Analysis** | `compare-binary`, `compare-swot`, `quadrant-quarter` | Pros/Cons, SWOT, Quadrants |
|
||||
| **Charts & Data** | `chart-bar`, `chart-line`, `chart-pie` | Trends, Distributions, Metrics |
|
||||
| **Sequence** | `sequence-timeline-simple`, `sequence-roadmap-vertical-simple`, `sequence-snake-steps-compact-card` | Timelines, Roadmaps, Processes |
|
||||
| **Lists** | `list-grid-candy-card-lite`, `list-row-horizontal-icon-arrow`, `list-column-simple-vertical-arrow` | Features, Bullet Points, Lists |
|
||||
| **Comparison** | `compare-binary-horizontal-underline-text-vs`, `compare-swot`, `quadrant-quarter-simple-card` | Pros/Cons, SWOT, Quadrants |
|
||||
| **Hierarchy** | `hierarchy-tree-tech-style-capsule-item`, `hierarchy-structure` | Org Charts, Structures |
|
||||
| **Charts** | `chart-column-simple`, `chart-bar-plain-text`, `chart-line-plain-text`, `chart-wordcloud` | Trends, Distributions, Metrics |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Smart Infographic(智能信息图)
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v1.4.0</span>
|
||||
<span class="version-badge">v1.4.9</span>
|
||||
|
||||
基于 AntV 信息图引擎,将长文本一键转成专业、美观的信息图。
|
||||
|
||||
@@ -14,8 +14,8 @@ Smart Infographic 使用 AI 分析文本,并基于 AntV 可视化引擎生成
|
||||
## 功能特性
|
||||
|
||||
- :material-robot: **AI 转换**:自动分析文本逻辑,提取要点并生成结构化图表
|
||||
- :material-palette: **专业模板**:内置 AntV 官方模板:列表、树、思维导图、对比表、流程图、统计图等
|
||||
- :material-magnify: **自动匹配图标**:根据内容自动选择最合适的 Material Design Icons
|
||||
- :material-palette: **70+ 专业模板**:内置多种 AntV 官方模板,包括列表、树图、路线图、时间线、对比图、SWOT、象限图及统计图表等
|
||||
- :material-magnify: **自动匹配图标**:内置图标搜索逻辑,支持 Iconify 图标和 unDraw 插图自动匹配
|
||||
- :material-download: **多格式导出**:支持下载 **SVG**、**PNG**、**独立 HTML**
|
||||
- :material-theme-light-dark: **主题支持**:适配深色/浅色模式
|
||||
- :material-cellphone-link: **响应式**:桌面与移动端都能良好展示
|
||||
@@ -37,10 +37,11 @@ Smart Infographic 使用 AI 分析文本,并基于 AntV 可视化引擎生成
|
||||
|
||||
| 分类 | 模板名称 | 典型场景 |
|
||||
|:---------|:--------------|:---------|
|
||||
| **列表与层级** | `list-grid`, `tree-vertical`, `mindmap` | 特性列表、组织结构、头脑风暴 |
|
||||
| **序列与关系** | `sequence-roadmap`, `relation-circle` | 路线图、循环流程、步骤拆解 |
|
||||
| **对比与分析** | `compare-binary`, `compare-swot`, `quadrant-quarter` | 优劣势、SWOT、象限分析 |
|
||||
| **图表与数据** | `chart-bar`, `chart-line`, `chart-pie` | 趋势、分布、指标对比 |
|
||||
| **时序与流程** | `sequence-timeline-simple`, `sequence-roadmap-vertical-simple`, `sequence-snake-steps-compact-card` | 时间线、路线图、步骤说明 |
|
||||
| **列表与网格** | `list-grid-candy-card-lite`, `list-row-horizontal-icon-arrow`, `list-column-simple-vertical-arrow` | 功能亮点、要点列举、清单 |
|
||||
| **对比与分析** | `compare-binary-horizontal-underline-text-vs`, `compare-swot`, `quadrant-quarter-simple-card` | 优劣势对比、SWOT 分析、象限图 |
|
||||
| **层级与结构** | `hierarchy-tree-tech-style-capsule-item`, `hierarchy-structure` | 组织架构、层级关系 |
|
||||
| **图表与数据** | `chart-column-simple`, `chart-bar-plain-text`, `chart-line-plain-text`, `chart-wordcloud` | 数据趋势、比例分布、数值对比 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Summary
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v0.1.0</span>
|
||||
|
||||
Generate concise summaries of long text content with key points extraction.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Summary plugin helps you quickly understand long pieces of text by generating concise summaries with extracted key points. It's perfect for:
|
||||
|
||||
- Summarizing long articles or documents
|
||||
- Extracting key points from conversations
|
||||
- Creating quick overviews of complex topics
|
||||
|
||||
## Features
|
||||
|
||||
- :material-text-box-search: **Smart Summarization**: AI-powered content analysis
|
||||
- :material-format-list-bulleted: **Key Points**: Extracted important highlights
|
||||
- :material-content-copy: **Easy Copy**: One-click copying of summaries
|
||||
- :material-tune: **Adjustable Length**: Control summary detail level
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the plugin file: [`summary.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary)
|
||||
2. Upload to OpenWebUI: **Admin Panel** → **Settings** → **Functions**
|
||||
3. Enable the plugin
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Get a long response from the AI or paste long text
|
||||
2. Click the **Summary** button in the message action bar
|
||||
3. View the generated summary with key points
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `summary_length` | string | `"medium"` | Length of summary (short/medium/long) |
|
||||
| `include_key_points` | boolean | `true` | Extract and list key points |
|
||||
| `language` | string | `"auto"` | Output language |
|
||||
|
||||
---
|
||||
|
||||
## Example Output
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
This document discusses the implementation of a new feature
|
||||
for the application, focusing on user experience improvements
|
||||
and performance optimizations.
|
||||
|
||||
### Key Points
|
||||
|
||||
- ✅ New user interface design improves accessibility
|
||||
- ✅ Backend optimizations reduce load times by 40%
|
||||
- ✅ Mobile responsiveness enhanced
|
||||
- ✅ Integration with third-party services simplified
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
!!! note "Prerequisites"
|
||||
- OpenWebUI v0.3.0 or later
|
||||
- Uses the active LLM model for summarization
|
||||
|
||||
---
|
||||
|
||||
## Source Code
|
||||
|
||||
[:fontawesome-brands-github: View on GitHub](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary){ .md-button }
|
||||
@@ -1,82 +0,0 @@
|
||||
# Summary(摘要)
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v0.1.0</span>
|
||||
|
||||
为长文本生成简洁摘要,并提取关键要点。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
Summary 插件可以快速理解长文本,生成精炼摘要并列出关键点,适合:
|
||||
|
||||
- 总结长文章或文档
|
||||
- 从对话中提炼要点
|
||||
- 为复杂主题制作快速概览
|
||||
|
||||
## 功能特性
|
||||
|
||||
- :material-text-box-search: **智能摘要**:AI 驱动的内容分析
|
||||
- :material-format-list-bulleted: **关键点**:提取重要信息
|
||||
- :material-content-copy: **便捷复制**:一键复制摘要
|
||||
- :material-tune: **长度可调**:可选择摘要详略程度
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
1. 下载插件文件:[`summary.py`](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary)
|
||||
2. 上传到 OpenWebUI:**Admin Panel** → **Settings** → **Functions**
|
||||
3. 启用插件
|
||||
|
||||
---
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 获取一段较长的 AI 回复或粘贴长文本
|
||||
2. 点击消息操作栏的 **Summary** 按钮
|
||||
3. 查看生成的摘要与关键点
|
||||
|
||||
---
|
||||
|
||||
## 配置项
|
||||
|
||||
| 选项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|---------|-------------|
|
||||
| `summary_length` | string | `"medium"` | 摘要长度(short/medium/long) |
|
||||
| `include_key_points` | boolean | `true` | 是否提取并列出关键点 |
|
||||
| `language` | string | `"auto"` | 输出语言 |
|
||||
|
||||
---
|
||||
|
||||
## 输出示例
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
This document discusses the implementation of a new feature
|
||||
for the application, focusing on user experience improvements
|
||||
and performance optimizations.
|
||||
|
||||
### Key Points
|
||||
|
||||
- ✅ New user interface design improves accessibility
|
||||
- ✅ Backend optimizations reduce load times by 40%
|
||||
- ✅ Mobile responsiveness enhanced
|
||||
- ✅ Integration with third-party services simplified
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行要求
|
||||
|
||||
!!! note "前置条件"
|
||||
- OpenWebUI v0.3.0 及以上
|
||||
- 使用当前会话的 LLM 模型进行摘要
|
||||
|
||||
---
|
||||
|
||||
## 源码
|
||||
|
||||
[:fontawesome-brands-github: 在 GitHub 查看](https://github.com/Fu-Jie/awesome-openwebui/tree/main/plugins/actions/summary){ .md-button }
|
||||
@@ -1,7 +1,7 @@
|
||||
# Async Context Compression
|
||||
|
||||
<span class="category-badge filter">Filter</span>
|
||||
<span class="version-badge">v1.1.0</span>
|
||||
<span class="version-badge">v1.1.3</span>
|
||||
|
||||
Reduces token consumption in long conversations through intelligent summarization while maintaining conversational coherence.
|
||||
|
||||
@@ -29,6 +29,11 @@ This is especially useful for:
|
||||
- :material-clock-fast: **Async Processing**: Non-blocking background compression
|
||||
- :material-memory: **Context Preservation**: Keeps important information
|
||||
- :material-currency-usd-off: **Cost Reduction**: Minimize token usage
|
||||
- :material-console: **Frontend Debugging**: Debug logs in browser console
|
||||
- :material-alert-circle-check: **Enhanced Error Reporting**: Clear error status notifications
|
||||
- :material-check-all: **Open WebUI v0.7.x Compatibility**: Dynamic DB session handling
|
||||
- :material-account-convert: **Improved Compatibility**: Summary role changed to `assistant`
|
||||
- :material-shield-check: **Enhanced Stability**: Resolved race conditions in state management
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Async Context Compression(异步上下文压缩)
|
||||
|
||||
<span class="category-badge filter">Filter</span>
|
||||
<span class="version-badge">v1.1.0</span>
|
||||
<span class="version-badge">v1.1.3</span>
|
||||
|
||||
通过智能摘要减少长对话的 token 消耗,同时保持对话连贯。
|
||||
|
||||
@@ -29,6 +29,11 @@ Async Context Compression 过滤器通过以下方式帮助管理长对话的 to
|
||||
- :material-clock-fast: **异步处理**:后台非阻塞压缩
|
||||
- :material-memory: **保留上下文**:尽量保留重要信息
|
||||
- :material-currency-usd-off: **降低成本**:减少 token 使用
|
||||
- :material-console: **前端调试**:支持浏览器控制台日志
|
||||
- :material-alert-circle-check: **增强错误报告**:清晰的错误状态通知
|
||||
- :material-check-all: **Open WebUI v0.7.x 兼容性**:动态数据库会话处理
|
||||
- :material-account-convert: **兼容性提升**:摘要角色改为 `assistant`
|
||||
- :material-shield-check: **稳定性增强**:解决状态管理竞态条件
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Filters act as middleware in the message pipeline:
|
||||
|
||||
Reduces token consumption in long conversations through intelligent summarization while maintaining coherence.
|
||||
|
||||
**Version:** 1.1.0
|
||||
**Version:** 1.1.3
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](async-context-compression.md)
|
||||
|
||||
@@ -46,6 +46,16 @@ Filters act as middleware in the message pipeline:
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](gemini-manifold-companion.md)
|
||||
|
||||
- :material-format-paint:{ .lg .middle } **Markdown Normalizer**
|
||||
|
||||
---
|
||||
|
||||
Fixes common Markdown formatting issues in LLM outputs, including Mermaid syntax, code blocks, and LaTeX formulas.
|
||||
|
||||
**Version:** 1.0.1
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](markdown_normalizer.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@ Filter 充当消息管线中的中间件:
|
||||
|
||||
通过智能总结减少长对话的 token 消耗,同时保持连贯性。
|
||||
|
||||
**版本:** 1.1.0
|
||||
**版本:** 1.1.3
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](async-context-compression.md)
|
||||
|
||||
@@ -46,6 +46,16 @@ Filter 充当消息管线中的中间件:
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](gemini-manifold-companion.md)
|
||||
|
||||
- :material-format-paint:{ .lg .middle } **Markdown Normalizer**
|
||||
|
||||
---
|
||||
|
||||
修复 LLM 输出中常见的 Markdown 格式问题,包括 Mermaid 语法、代码块和 LaTeX 公式。
|
||||
|
||||
**版本:** 1.0.1
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](markdown_normalizer.zh.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
46
docs/plugins/filters/markdown_normalizer.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Markdown Normalizer Filter
|
||||
|
||||
A production-grade content normalizer filter for Open WebUI that fixes common Markdown formatting issues in LLM outputs. It ensures that code blocks, LaTeX formulas, Mermaid diagrams, and other Markdown elements are rendered correctly.
|
||||
|
||||
## Features
|
||||
|
||||
* **Mermaid Syntax Fix**: Automatically fixes common Mermaid syntax errors, such as unquoted node labels (including multi-line labels and citations) and unclosed subgraphs, ensuring diagrams render correctly.
|
||||
* **Frontend Console Debugging**: Supports printing structured debug logs directly to the browser console (F12) for easier troubleshooting.
|
||||
* **Code Block Formatting**: Fixes broken code block prefixes, suffixes, and indentation.
|
||||
* **LaTeX Normalization**: Standardizes LaTeX formula delimiters (`\[` -> `$$`, `\(` -> `$`).
|
||||
* **Thought Tag Normalization**: Unifies thought tags (`<think>`, `<thinking>` -> `<thought>`).
|
||||
* **Escape Character Fix**: Cleans up excessive escape characters (`\\n`, `\\t`).
|
||||
* **List Formatting**: Ensures proper newlines in list items.
|
||||
* **Heading Fix**: Adds missing spaces in headings (`#Heading` -> `# Heading`).
|
||||
* **Table Fix**: Adds missing closing pipes in tables.
|
||||
* **XML Cleanup**: Removes leftover XML artifacts.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin in Open WebUI.
|
||||
2. Enable the filter globally or for specific models.
|
||||
3. Configure the enabled fixes in the **Valves** settings.
|
||||
4. (Optional) **Show Debug Log** is enabled by default in Valves. This prints structured logs to the browser console (F12).
|
||||
> [!WARNING]
|
||||
> As this is an initial version, some "negative fixes" might occur (e.g., breaking valid Markdown). If you encounter issues, please check the console logs, copy the "Original" vs "Normalized" content, and submit an issue.
|
||||
|
||||
## Configuration (Valves)
|
||||
|
||||
* `priority`: Filter priority (default: 50).
|
||||
* `enable_escape_fix`: Fix excessive escape characters.
|
||||
* `enable_thought_tag_fix`: Normalize thought tags.
|
||||
* `enable_code_block_fix`: Fix code block formatting.
|
||||
* `enable_latex_fix`: Normalize LaTeX formulas.
|
||||
* `enable_list_fix`: Fix list item newlines (Experimental).
|
||||
* `enable_unclosed_block_fix`: Auto-close unclosed code blocks.
|
||||
* `enable_fullwidth_symbol_fix`: Fix full-width symbols in code blocks.
|
||||
* `enable_mermaid_fix`: Fix Mermaid syntax errors.
|
||||
* `enable_heading_fix`: Fix missing space in headings.
|
||||
* `enable_table_fix`: Fix missing closing pipe in tables.
|
||||
* `enable_xml_tag_cleanup`: Cleanup leftover XML tags.
|
||||
* `show_status`: Show status notification when fixes are applied.
|
||||
* `show_debug_log`: Print debug logs to browser console.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
46
docs/plugins/filters/markdown_normalizer.zh.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Markdown 格式化过滤器 (Markdown Normalizer)
|
||||
|
||||
这是一个用于 Open WebUI 的生产级内容格式化过滤器,旨在修复 LLM 输出中常见的 Markdown 格式问题。它能确保代码块、LaTeX 公式、Mermaid 图表和其他 Markdown 元素被正确渲染。
|
||||
|
||||
## 功能特性
|
||||
|
||||
* **Mermaid 语法修复**: 自动修复常见的 Mermaid 语法错误,如未加引号的节点标签(支持多行标签和引用标记)和未闭合的子图 (Subgraph),确保图表能正确渲染。
|
||||
* **前端控制台调试**: 支持将结构化的调试日志直接打印到浏览器控制台 (F12),方便排查问题。
|
||||
* **代码块格式化**: 修复破损的代码块前缀、后缀和缩进问题。
|
||||
* **LaTeX 规范化**: 标准化 LaTeX 公式定界符 (`\[` -> `$$`, `\(` -> `$`)。
|
||||
* **思维标签规范化**: 统一思维链标签 (`<think>`, `<thinking>` -> `<thought>`)。
|
||||
* **转义字符修复**: 清理过度的转义字符 (`\\n`, `\\t`)。
|
||||
* **列表格式化**: 确保列表项有正确的换行。
|
||||
* **标题修复**: 修复标题中缺失的空格 (`#标题` -> `# 标题`)。
|
||||
* **表格修复**: 修复表格中缺失的闭合管道符。
|
||||
* **XML 清理**: 移除残留的 XML 标签。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 在 Open WebUI 中安装此插件。
|
||||
2. 全局启用或为特定模型启用此过滤器。
|
||||
3. 在 **Valves** 设置中配置需要启用的修复项。
|
||||
4. (可选) **显示调试日志 (Show Debug Log)** 在 Valves 中默认开启。这会将结构化的日志打印到浏览器控制台 (F12)。
|
||||
> [!WARNING]
|
||||
> 由于这是初版,可能会出现“负向修复”的情况(例如破坏了原本正确的格式)。如果您遇到问题,请务必查看控制台日志,复制“原始 (Original)”与“规范化 (Normalized)”的内容对比,并提交 Issue 反馈。
|
||||
|
||||
## 配置项 (Valves)
|
||||
|
||||
* `priority`: 过滤器优先级 (默认: 50)。
|
||||
* `enable_escape_fix`: 修复过度的转义字符。
|
||||
* `enable_thought_tag_fix`: 规范化思维标签。
|
||||
* `enable_code_block_fix`: 修复代码块格式。
|
||||
* `enable_latex_fix`: 规范化 LaTeX 公式。
|
||||
* `enable_list_fix`: 修复列表项换行 (实验性)。
|
||||
* `enable_unclosed_block_fix`: 自动闭合未闭合的代码块。
|
||||
* `enable_fullwidth_symbol_fix`: 修复代码块中的全角符号。
|
||||
* `enable_mermaid_fix`: 修复 Mermaid 语法错误。
|
||||
* `enable_heading_fix`: 修复标题中缺失的空格。
|
||||
* `enable_table_fix`: 修复表格中缺失的闭合管道符。
|
||||
* `enable_xml_tag_cleanup`: 清理残留的 XML 标签。
|
||||
* `show_status`: 应用修复时显示状态通知。
|
||||
* `show_debug_log`: 在浏览器控制台打印调试日志。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
@@ -53,7 +53,6 @@ OpenWebUI supports four types of plugins, each serving a different purpose:
|
||||
| [Knowledge Card](actions/knowledge-card.md) | Action | Create beautiful learning flashcards | 0.2.0 |
|
||||
| [Export to Excel](actions/export-to-excel.md) | Action | Export chat history to Excel files | 1.0.0 |
|
||||
| [Export to Word](actions/export-to-word.md) | Action | Export chat content to Word (.docx) with formatting | 0.1.0 |
|
||||
| [Summary](actions/summary.md) | Action | Text summarization tool | 1.0.0 |
|
||||
| [Async Context Compression](filters/async-context-compression.md) | Filter | Intelligent context compression | 1.0.0 |
|
||||
| [Context Enhancement](filters/context-enhancement.md) | Filter | Enhance chat context | 1.0.0 |
|
||||
| [Gemini Manifold Companion](filters/gemini-manifold-companion.md) | Filter | Companion for Gemini Manifold | 1.0.0 |
|
||||
|
||||
@@ -53,7 +53,6 @@ OpenWebUI 支持四种类型的插件,每种都有不同的用途:
|
||||
| [Knowledge Card(知识卡片)](actions/knowledge-card.md) | Action | 生成精美学习卡片 | 0.2.0 |
|
||||
| [Export to Excel(导出到 Excel)](actions/export-to-excel.md) | Action | 导出聊天记录为 Excel | 1.0.0 |
|
||||
| [Export to Word(导出为 Word)](actions/export-to-word.md) | Action | 将聊天内容导出为 Word (.docx) 并保留格式 | 0.1.0 |
|
||||
| [Summary(摘要)](actions/summary.md) | Action | 文本摘要工具 | 1.0.0 |
|
||||
| [Async Context Compression(异步上下文压缩)](filters/async-context-compression.md) | Filter | 智能上下文压缩 | 1.0.0 |
|
||||
| [Context Enhancement(上下文增强)](filters/context-enhancement.md) | Filter | 提升对话上下文 | 1.0.0 |
|
||||
| [Gemini Manifold Companion](filters/gemini-manifold-companion.md) | Filter | Gemini Manifold 伴侣 | 1.0.0 |
|
||||
|
||||
@@ -187,7 +187,6 @@ nav:
|
||||
- Knowledge Card: plugins/actions/knowledge-card.md
|
||||
- Export to Excel: plugins/actions/export-to-excel.md
|
||||
- Export to Word: plugins/actions/export-to-word.md
|
||||
- Summary: plugins/actions/summary.md
|
||||
- Filters:
|
||||
- plugins/filters/index.md
|
||||
- Async Context Compression: plugins/filters/async-context-compression.md
|
||||
|
||||
83
plugins/actions/deep-dive/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 🌊 Deep Dive
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.0.0 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.
|
||||
|
||||
## 🔥 What's New in v1.0.0
|
||||
|
||||
- ✨ **Thinking Chain Structure**: Moves from surface understanding to deep strategic action.
|
||||
- 🔍 **Phase 01: The Context**: Panoramic view of the situation and background.
|
||||
- 🧠 **Phase 02: The Logic**: Deconstruction of the underlying reasoning and mental models.
|
||||
- 💎 **Phase 03: The Insight**: Extraction of non-obvious value and hidden implications.
|
||||
- 🚀 **Phase 04: The Path**: Definition of specific, prioritized strategic directions.
|
||||
- 🎨 **Premium UI**: Modern, process-oriented design with a "Thinking Line" timeline.
|
||||
- 🌗 **Theme Adaptive**: Automatically adapts to OpenWebUI's light/dark theme.
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- 🌊 **Deep Thinking**: Not just a summary, but a full deconstruction of content.
|
||||
- 🧠 **Logical Analysis**: Reveals how arguments are built and identifies hidden assumptions.
|
||||
- 💎 **Value Extraction**: Finds the "Aha!" moments and blind spots.
|
||||
- 🚀 **Action Oriented**: Translates deep understanding into immediate, actionable steps.
|
||||
- 🌍 **Multi-language**: Automatically adapts to the user's preferred language.
|
||||
- 🌗 **Theme Support**: Seamlessly switches between light and dark themes based on OpenWebUI settings.
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
1. **Input Content**: Provide any text, article, or meeting notes in the chat.
|
||||
2. **Trigger Deep Dive**: Click the **Deep Dive** action button.
|
||||
3. **Explore the Chain**: Follow the visual timeline from Context to Path.
|
||||
|
||||
## ⚙️ Configuration (Valves)
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **Show Status (SHOW_STATUS)** | `True` | Whether to show status updates during the thinking process. |
|
||||
| **Model ID (MODEL_ID)** | `Empty` | LLM model for analysis. Empty = use current model. |
|
||||
| **Min Text Length (MIN_TEXT_LENGTH)** | `200` | Minimum characters required for a meaningful deep dive. |
|
||||
| **Clear Previous HTML (CLEAR_PREVIOUS_HTML)** | `True` | Whether to clear previous plugin results. |
|
||||
| **Message Count (MESSAGE_COUNT)** | `1` | Number of recent messages to analyze. |
|
||||
|
||||
## 🌗 Theme Support
|
||||
|
||||
The plugin automatically detects and adapts to OpenWebUI's theme settings:
|
||||
|
||||
- **Detection Priority**:
|
||||
1. Parent document `<meta name="theme-color">` tag
|
||||
2. Parent document `html/body` class or `data-theme` attribute
|
||||
3. System preference via `prefers-color-scheme: dark`
|
||||
|
||||
- **Requirements**: For best results, enable **iframe Sandbox Allow Same Origin** in OpenWebUI:
|
||||
- Go to **Settings** → **Interface** → **Artifacts** → Check **iframe Sandbox Allow Same Origin**
|
||||
|
||||
## 🎨 Visual Preview
|
||||
|
||||
The plugin generates a structured thinking timeline:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🌊 Deep Dive Analysis │
|
||||
│ 👤 User 📅 Date 📊 Word count │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔍 Phase 01: The Context │
|
||||
│ [High-level panoramic view] │
|
||||
│ │
|
||||
│ 🧠 Phase 02: The Logic │
|
||||
│ • Reasoning structure... │
|
||||
│ • Hidden assumptions... │
|
||||
│ │
|
||||
│ 💎 Phase 03: The Insight │
|
||||
│ • Non-obvious value... │
|
||||
│ • Blind spots revealed... │
|
||||
│ │
|
||||
│ 🚀 Phase 04: The Path │
|
||||
│ ▸ Priority Action 1... │
|
||||
│ ▸ Priority Action 2... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📂 Files
|
||||
|
||||
- `deep_dive.py` - English version
|
||||
- `deep_dive_cn.py` - Chinese version (精读)
|
||||
83
plugins/actions/deep-dive/README_CN.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 📖 精读
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.0.0 | **项目:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
|
||||
|
||||
## 🔥 v1.0.0 更新内容
|
||||
|
||||
- ✨ **思维链结构**: 从表面理解一步步深入到战略行动。
|
||||
- 🔍 **阶段 01: 全景 (The Context)**: 提供情境与背景的高层级全景视图。
|
||||
- 🧠 **阶段 02: 脉络 (The Logic)**: 解构底层推理逻辑与思维模型。
|
||||
- 💎 **阶段 03: 洞察 (The Insight)**: 提取非显性价值与隐藏的深层含义。
|
||||
- 🚀 **阶段 04: 路径 (The Path)**: 定义具体的、按优先级排列的战略方向。
|
||||
- 🎨 **高端 UI**: 现代化的过程导向设计,带有"思维导火索"时间轴。
|
||||
- 🌗 **主题自适应**: 自动适配 OpenWebUI 的深色/浅色主题。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 📖 **深度思考**: 不仅仅是摘要,而是对内容的全面解构。
|
||||
- 🧠 **逻辑分析**: 揭示论点是如何构建的,识别隐藏的假设。
|
||||
- 💎 **价值提取**: 发现"原来如此"的时刻与思维盲点。
|
||||
- 🚀 **行动导向**: 将深度理解转化为立即、可执行的步骤。
|
||||
- 🌍 **多语言支持**: 自动适配用户的偏好语言。
|
||||
- 🌗 **主题支持**: 根据 OpenWebUI 设置自动切换深色/浅色主题。
|
||||
|
||||
## 🚀 如何使用
|
||||
|
||||
1. **输入内容**: 在聊天中提供任何文本、文章或会议记录。
|
||||
2. **触发精读**: 点击 **精读** 操作按钮。
|
||||
3. **探索思维链**: 沿着视觉时间轴从"全景"探索到"路径"。
|
||||
|
||||
## ⚙️ 配置参数 (Valves)
|
||||
|
||||
| 参数 | 默认值 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| **显示状态 (SHOW_STATUS)** | `True` | 是否在思维过程中显示状态更新。 |
|
||||
| **模型 ID (MODEL_ID)** | `空` | 用于分析的 LLM 模型。留空 = 使用当前模型。 |
|
||||
| **最小文本长度 (MIN_TEXT_LENGTH)** | `200` | 进行有意义的精读所需的最小字符数。 |
|
||||
| **清除旧 HTML (CLEAR_PREVIOUS_HTML)** | `True` | 是否清除之前的插件结果。 |
|
||||
| **消息数量 (MESSAGE_COUNT)** | `1` | 要分析的最近消息数量。 |
|
||||
|
||||
## 🌗 主题支持
|
||||
|
||||
插件会自动检测并适配 OpenWebUI 的主题设置:
|
||||
|
||||
- **检测优先级**:
|
||||
1. 父文档 `<meta name="theme-color">` 标签
|
||||
2. 父文档 `html/body` 的 class 或 `data-theme` 属性
|
||||
3. 系统偏好 `prefers-color-scheme: dark`
|
||||
|
||||
- **环境要求**: 为获得最佳效果,请在 OpenWebUI 中启用 **iframe Sandbox Allow Same Origin**:
|
||||
- 进入 **设置** → **界面** → **Artifacts** → 勾选 **iframe Sandbox Allow Same Origin**
|
||||
|
||||
## 🎨 视觉预览
|
||||
|
||||
插件生成结构化的思维时间轴:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📖 精读分析报告 │
|
||||
│ 👤 用户 📅 日期 📊 字数 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔍 阶段 01: 全景 (The Context) │
|
||||
│ [高层级全景视图内容] │
|
||||
│ │
|
||||
│ 🧠 阶段 02: 脉络 (The Logic) │
|
||||
│ • 推理结构分析... │
|
||||
│ • 隐藏假设识别... │
|
||||
│ │
|
||||
│ 💎 阶段 03: 洞察 (The Insight) │
|
||||
│ • 非显性价值提取... │
|
||||
│ • 思维盲点揭示... │
|
||||
│ │
|
||||
│ 🚀 阶段 04: 路径 (The Path) │
|
||||
│ ▸ 优先级行动 1... │
|
||||
│ ▸ 优先级行动 2... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📂 文件说明
|
||||
|
||||
- `deep_dive.py` - 英文版 (Deep Dive)
|
||||
- `deep_dive_cn.py` - 中文版 (精读)
|
||||
BIN
plugins/actions/deep-dive/deep_dive.png
Normal file
|
After Width: | Height: | Size: 783 KiB |
884
plugins/actions/deep-dive/deep_dive.py
Normal file
@@ -0,0 +1,884 @@
|
||||
"""
|
||||
title: Deep Dive
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 1.0.0
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiA3djE0Ii8+PHBhdGggZD0iTTMgMThhMSAxIDAgMCAxLTEtMVY0YTEgMSAwIDAgMSAxLTFoNWE0IDQgMCAwIDEgNCA0IDQgNCAwIDAgMSA0LTRoNWExIDEgMCAwIDEgMSAxdjEzYTEgMSAwIDAgMS0xIDFoLTZhMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAtMy0zeiIvPjxwYXRoIGQ9Ik02IDEyaDIiLz48cGF0aCBkPSJNMTYgMTJoMiIvPjwvc3ZnPg==
|
||||
requirements: markdown
|
||||
description: A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||
from datetime import datetime
|
||||
|
||||
# Third-party imports
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
import markdown
|
||||
|
||||
# OpenWebUI imports
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Logging setup
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# HTML Template - Process-Oriented Design with Theme Support
|
||||
# =================================================================
|
||||
HTML_WRAPPER_TEMPLATE = """
|
||||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
:root {
|
||||
--dd-bg-primary: #ffffff;
|
||||
--dd-bg-secondary: #f8fafc;
|
||||
--dd-bg-tertiary: #f1f5f9;
|
||||
--dd-text-primary: #0f172a;
|
||||
--dd-text-secondary: #334155;
|
||||
--dd-text-dim: #64748b;
|
||||
--dd-border: #e2e8f0;
|
||||
--dd-accent: #3b82f6;
|
||||
--dd-accent-soft: #eff6ff;
|
||||
--dd-header-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
--dd-shadow: 0 10px 40px rgba(0,0,0,0.06);
|
||||
--dd-code-bg: #f1f5f9;
|
||||
}
|
||||
.theme-dark {
|
||||
--dd-bg-primary: #1e293b;
|
||||
--dd-bg-secondary: #0f172a;
|
||||
--dd-bg-tertiary: #334155;
|
||||
--dd-text-primary: #f1f5f9;
|
||||
--dd-text-secondary: #e2e8f0;
|
||||
--dd-text-dim: #94a3b8;
|
||||
--dd-border: #475569;
|
||||
--dd-accent: #60a5fa;
|
||||
--dd-accent-soft: rgba(59, 130, 246, 0.15);
|
||||
--dd-header-gradient: linear-gradient(135deg, #0f172a 0%, #1e1e2e 100%);
|
||||
--dd-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
--dd-code-bg: #334155;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.plugin-item {
|
||||
background: var(--dd-bg-primary);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--dd-shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dd-border);
|
||||
}
|
||||
/* STYLES_INSERTION_POINT */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<!-- CONTENT_INSERTION_POINT -->
|
||||
</div>
|
||||
<!-- SCRIPTS_INSERTION_POINT -->
|
||||
<script>
|
||||
(function() {
|
||||
const parseColorLuma = (colorStr) => {
|
||||
if (!colorStr) return null;
|
||||
let m = colorStr.match(/^#?([0-9a-f]{6})$/i);
|
||||
if (m) {
|
||||
const hex = m[1];
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
m = colorStr.match(/rgba?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/i);
|
||||
if (m) {
|
||||
const r = parseInt(m[1], 10);
|
||||
const g = parseInt(m[2], 10);
|
||||
const b = parseInt(m[3], 10);
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const getThemeFromMeta = (doc) => {
|
||||
const metas = Array.from((doc || document).querySelectorAll('meta[name="theme-color"]'));
|
||||
if (!metas.length) return null;
|
||||
const color = metas[metas.length - 1].content.trim();
|
||||
const luma = parseColorLuma(color);
|
||||
if (luma === null) return null;
|
||||
return luma < 0.5 ? 'dark' : 'light';
|
||||
};
|
||||
const getParentDocumentSafe = () => {
|
||||
try {
|
||||
if (!window.parent || window.parent === window) return null;
|
||||
const pDoc = window.parent.document;
|
||||
void pDoc.title;
|
||||
return pDoc;
|
||||
} catch (err) { return null; }
|
||||
};
|
||||
const getThemeFromParentClass = () => {
|
||||
try {
|
||||
if (!window.parent || window.parent === window) return null;
|
||||
const pDoc = window.parent.document;
|
||||
const html = pDoc.documentElement;
|
||||
const body = pDoc.body;
|
||||
const htmlClass = html ? html.className : '';
|
||||
const bodyClass = body ? body.className : '';
|
||||
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
||||
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) return 'dark';
|
||||
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) return 'light';
|
||||
return null;
|
||||
} catch (err) { return null; }
|
||||
};
|
||||
const setTheme = () => {
|
||||
const parentDoc = getParentDocumentSafe();
|
||||
const metaTheme = parentDoc ? getThemeFromMeta(parentDoc) : null;
|
||||
const parentClassTheme = getThemeFromParentClass();
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const chosen = metaTheme || parentClassTheme || (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('theme-dark', chosen === 'dark');
|
||||
};
|
||||
setTheme();
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', setTheme);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# LLM Prompts - Deep Dive Thinking Chain
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
You are a Deep Dive Analyst. Your goal is to guide the user through a comprehensive thinking process, moving from surface understanding to deep strategic action.
|
||||
|
||||
## Thinking Structure (STRICT)
|
||||
|
||||
You MUST analyze the input across these four specific dimensions:
|
||||
|
||||
### 1. 🔍 The Context (What?)
|
||||
Provide a high-level panoramic view. What is this content about? What is the core situation, background, or problem being addressed? (2-3 paragraphs)
|
||||
|
||||
### 2. 🧠 The Logic (Why?)
|
||||
Deconstruct the underlying structure. How is the argument built? What is the reasoning, the hidden assumptions, or the mental models at play? (Bullet points)
|
||||
|
||||
### 3. 💎 The Insight (So What?)
|
||||
Extract the non-obvious value. What are the "Aha!" moments? What are the implications, the blind spots, or the unique perspectives revealed? (Bullet points)
|
||||
|
||||
### 4. 🚀 The Path (Now What?)
|
||||
Define the strategic direction. What are the specific, prioritized next steps? How can this knowledge be applied immediately? (Actionable steps)
|
||||
|
||||
## Rules
|
||||
- Output in the user's specified language.
|
||||
- Maintain a professional, analytical, yet inspiring tone.
|
||||
- Focus on the *process* of understanding, not just the result.
|
||||
- No greetings or meta-commentary.
|
||||
"""
|
||||
|
||||
USER_PROMPT = """
|
||||
Initiate a Deep Dive into the following content:
|
||||
|
||||
**User Context:**
|
||||
- User: {user_name}
|
||||
- Time: {current_date_time_str}
|
||||
- Language: {user_language}
|
||||
|
||||
**Content to Analyze:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
Please execute the full thinking chain: Context → Logic → Insight → Path.
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# Premium CSS Design - Deep Dive Theme
|
||||
# =================================================================
|
||||
|
||||
CSS_TEMPLATE = """
|
||||
.deep-dive {
|
||||
font-family: 'Inter', -apple-system, system-ui, sans-serif;
|
||||
color: var(--dd-text-secondary);
|
||||
}
|
||||
|
||||
.dd-header {
|
||||
background: var(--dd-header-gradient);
|
||||
padding: 40px 32px;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dd-header-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dd-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dd-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dd-body {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
position: relative;
|
||||
background: var(--dd-bg-primary);
|
||||
}
|
||||
|
||||
/* The Thinking Line */
|
||||
.dd-body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 52px;
|
||||
top: 40px;
|
||||
bottom: 40px;
|
||||
width: 2px;
|
||||
background: var(--dd-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dd-step {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dd-step-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--dd-bg-primary);
|
||||
border: 2px solid var(--dd-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dd-step:hover .dd-step-icon {
|
||||
border-color: var(--dd-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dd-step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dd-step-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dd-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dd-step-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--dd-text-primary);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.dd-text {
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dd-text p { margin-bottom: 16px; }
|
||||
.dd-text p:last-child { margin-bottom: 0; }
|
||||
|
||||
.dd-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dd-list-item {
|
||||
background: var(--dd-bg-secondary);
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid var(--dd-border);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dd-list-item:hover {
|
||||
background: var(--dd-bg-tertiary);
|
||||
border-left-color: var(--dd-accent);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.dd-list-item strong {
|
||||
color: var(--dd-text-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dd-path-item {
|
||||
background: var(--dd-accent-soft);
|
||||
border-left-color: var(--dd-accent);
|
||||
}
|
||||
|
||||
.dd-footer {
|
||||
padding: 24px 32px;
|
||||
background: var(--dd-bg-secondary);
|
||||
border-top: 1px solid var(--dd-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--dd-text-dim);
|
||||
}
|
||||
|
||||
.dd-tag {
|
||||
padding: 2px 8px;
|
||||
background: var(--dd-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dd-text code,
|
||||
.dd-list-item code {
|
||||
background: var(--dd-code-bg);
|
||||
color: var(--dd-text-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.dd-list-item em {
|
||||
font-style: italic;
|
||||
color: var(--dd-text-dim);
|
||||
}
|
||||
"""
|
||||
|
||||
CONTENT_TEMPLATE = """
|
||||
<div class="deep-dive">
|
||||
<div class="dd-header">
|
||||
<div class="dd-header-badge">Thinking Process</div>
|
||||
<h1 class="dd-title">Deep Dive Analysis</h1>
|
||||
<div class="dd-meta">
|
||||
<span>👤 {user_name}</span>
|
||||
<span>📅 {current_date_time_str}</span>
|
||||
<span>📊 {word_count} words</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-body">
|
||||
<!-- Step 1: Context -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">🔍</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 01</div>
|
||||
<h2 class="dd-step-title">The Context</h2>
|
||||
<div class="dd-text">{context_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Logic -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">🧠</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 02</div>
|
||||
<h2 class="dd-step-title">The Logic</h2>
|
||||
<div class="dd-text">{logic_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Insight -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">💎</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 03</div>
|
||||
<h2 class="dd-step-title">The Insight</h2>
|
||||
<div class="dd-text">{insight_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Path -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">🚀</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 04</div>
|
||||
<h2 class="dd-step-title">The Path</h2>
|
||||
<div class="dd-text">{path_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-footer">
|
||||
<span>Deep Dive Engine v1.0</span>
|
||||
<span><span class="dd-tag">AI-Powered</span></span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show operation status updates.",
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="LLM Model ID for analysis. Empty = use current model.",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200,
|
||||
description="Minimum text length for deep dive (chars).",
|
||||
)
|
||||
CLEAR_PREVIOUS_HTML: bool = Field(
|
||||
default=True,
|
||||
description="Whether to clear previous plugin results.",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="Number of recent messages to analyze.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""Safely extracts user context information."""
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_data = __user__[0] if __user__ else {}
|
||||
elif isinstance(__user__, dict):
|
||||
user_data = __user__
|
||||
else:
|
||||
user_data = {}
|
||||
|
||||
return {
|
||||
"user_id": user_data.get("id", "unknown_user"),
|
||||
"user_name": user_data.get("name", "User"),
|
||||
"user_language": user_data.get("language", "en-US"),
|
||||
}
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""Parse LLM output and convert to styled HTML."""
|
||||
# Extract sections using flexible regex
|
||||
context_match = re.search(
|
||||
r"###\s*1\.\s*🔍?\s*The Context\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
logic_match = re.search(
|
||||
r"###\s*2\.\s*🧠?\s*The Logic\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
insight_match = re.search(
|
||||
r"###\s*3\.\s*💎?\s*The Insight\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
path_match = re.search(
|
||||
r"###\s*4\.\s*🚀?\s*The Path\s*\((.*?)\)\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Fallback if numbering is different
|
||||
if not context_match:
|
||||
context_match = re.search(
|
||||
r"###\s*🔍?\s*The Context.*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not logic_match:
|
||||
logic_match = re.search(
|
||||
r"###\s*🧠?\s*The Logic.*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not insight_match:
|
||||
insight_match = re.search(
|
||||
r"###\s*💎?\s*The Insight.*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not path_match:
|
||||
path_match = re.search(
|
||||
r"###\s*🚀?\s*The Path.*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
context_md = (
|
||||
context_match.group(1 if context_match.lastindex == 1 else 2).strip()
|
||||
if context_match
|
||||
else ""
|
||||
)
|
||||
logic_md = (
|
||||
logic_match.group(1 if logic_match.lastindex == 1 else 2).strip()
|
||||
if logic_match
|
||||
else ""
|
||||
)
|
||||
insight_md = (
|
||||
insight_match.group(1 if insight_match.lastindex == 1 else 2).strip()
|
||||
if insight_match
|
||||
else ""
|
||||
)
|
||||
path_md = (
|
||||
path_match.group(1 if path_match.lastindex == 1 else 2).strip()
|
||||
if path_match
|
||||
else ""
|
||||
)
|
||||
|
||||
if not any([context_md, logic_md, insight_md, path_md]):
|
||||
context_md = llm_output.strip()
|
||||
logger.warning("LLM output did not follow format. Using as context.")
|
||||
|
||||
md_extensions = ["nl2br"]
|
||||
|
||||
context_html = (
|
||||
markdown.markdown(context_md, extensions=md_extensions)
|
||||
if context_md
|
||||
else '<p class="dd-no-content">No context extracted.</p>'
|
||||
)
|
||||
logic_html = (
|
||||
self._process_list_items(logic_md, "logic")
|
||||
if logic_md
|
||||
else '<p class="dd-no-content">No logic deconstructed.</p>'
|
||||
)
|
||||
insight_html = (
|
||||
self._process_list_items(insight_md, "insight")
|
||||
if insight_md
|
||||
else '<p class="dd-no-content">No insights found.</p>'
|
||||
)
|
||||
path_html = (
|
||||
self._process_list_items(path_md, "path")
|
||||
if path_md
|
||||
else '<p class="dd-no-content">No path defined.</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"context_html": context_html,
|
||||
"logic_html": logic_html,
|
||||
"insight_html": insight_html,
|
||||
"path_html": path_html,
|
||||
}
|
||||
|
||||
def _process_list_items(self, md_content: str, section_type: str) -> str:
|
||||
"""Convert markdown list to styled HTML cards with full markdown support."""
|
||||
lines = md_content.strip().split("\n")
|
||||
items = []
|
||||
current_paragraph = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# Check for list item (bullet or numbered)
|
||||
bullet_match = re.match(r"^[-*]\s+(.+)$", line)
|
||||
numbered_match = re.match(r"^\d+\.\s+(.+)$", line)
|
||||
|
||||
if bullet_match or numbered_match:
|
||||
# Flush any accumulated paragraph
|
||||
if current_paragraph:
|
||||
para_text = " ".join(current_paragraph)
|
||||
para_html = self._convert_inline_markdown(para_text)
|
||||
items.append(f"<p>{para_html}</p>")
|
||||
current_paragraph = []
|
||||
|
||||
# Extract the list item content
|
||||
text = (
|
||||
bullet_match.group(1) if bullet_match else numbered_match.group(1)
|
||||
)
|
||||
|
||||
# Handle bold title pattern: **Title:** Description or **Title**: Description
|
||||
title_match = re.match(r"\*\*(.+?)\*\*[:\s]*(.*)$", text)
|
||||
if title_match:
|
||||
title = self._convert_inline_markdown(title_match.group(1))
|
||||
desc = self._convert_inline_markdown(title_match.group(2).strip())
|
||||
path_class = "dd-path-item" if section_type == "path" else ""
|
||||
item_html = f'<div class="dd-list-item {path_class}"><strong>{title}</strong>{desc}</div>'
|
||||
else:
|
||||
text_html = self._convert_inline_markdown(text)
|
||||
path_class = "dd-path-item" if section_type == "path" else ""
|
||||
item_html = (
|
||||
f'<div class="dd-list-item {path_class}">{text_html}</div>'
|
||||
)
|
||||
items.append(item_html)
|
||||
elif line and not line.startswith("#"):
|
||||
# Accumulate paragraph text
|
||||
current_paragraph.append(line)
|
||||
elif not line and current_paragraph:
|
||||
# Empty line ends paragraph
|
||||
para_text = " ".join(current_paragraph)
|
||||
para_html = self._convert_inline_markdown(para_text)
|
||||
items.append(f"<p>{para_html}</p>")
|
||||
current_paragraph = []
|
||||
|
||||
# Flush remaining paragraph
|
||||
if current_paragraph:
|
||||
para_text = " ".join(current_paragraph)
|
||||
para_html = self._convert_inline_markdown(para_text)
|
||||
items.append(f"<p>{para_html}</p>")
|
||||
|
||||
if items:
|
||||
return f'<div class="dd-list">{" ".join(items)}</div>'
|
||||
return f'<p class="dd-no-content">No items found.</p>'
|
||||
|
||||
def _convert_inline_markdown(self, text: str) -> str:
|
||||
"""Convert inline markdown (bold, italic, code) to HTML."""
|
||||
# Convert inline code: `code` -> <code>code</code>
|
||||
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
||||
# Convert bold: **text** -> <strong>text</strong>
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
# Convert italic: *text* -> <em>text</em> (but not inside **)
|
||||
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
|
||||
return text
|
||||
|
||||
async def _emit_status(
|
||||
self,
|
||||
emitter: Optional[Callable[[Any], Awaitable[None]]],
|
||||
description: str,
|
||||
done: bool = False,
|
||||
):
|
||||
"""Emits a status update event."""
|
||||
if self.valves.SHOW_STATUS and emitter:
|
||||
await emitter(
|
||||
{"type": "status", "data": {"description": description, "done": done}}
|
||||
)
|
||||
|
||||
async def _emit_notification(
|
||||
self,
|
||||
emitter: Optional[Callable[[Any], Awaitable[None]]],
|
||||
content: str,
|
||||
ntype: str = "info",
|
||||
):
|
||||
"""Emits a notification event."""
|
||||
if emitter:
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": ntype, "content": content}}
|
||||
)
|
||||
|
||||
def _remove_existing_html(self, content: str) -> str:
|
||||
"""Removes existing plugin-generated HTML."""
|
||||
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
|
||||
return re.sub(pattern, "", content).strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""Extract text from message content."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
def _merge_html(
|
||||
self,
|
||||
existing_html: str,
|
||||
new_content: str,
|
||||
new_styles: str = "",
|
||||
user_language: str = "en-US",
|
||||
) -> str:
|
||||
"""Merges new content into HTML container."""
|
||||
if "<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html:
|
||||
base_html = re.sub(r"^```html\s*", "", existing_html)
|
||||
base_html = re.sub(r"\s*```$", "", base_html)
|
||||
else:
|
||||
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
|
||||
|
||||
wrapped = f'<div class="plugin-item">\n{new_content}\n</div>'
|
||||
|
||||
if new_styles:
|
||||
base_html = base_html.replace(
|
||||
"/* STYLES_INSERTION_POINT */",
|
||||
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
||||
)
|
||||
|
||||
base_html = base_html.replace(
|
||||
"<!-- CONTENT_INSERTION_POINT -->",
|
||||
f"{wrapped}\n<!-- CONTENT_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def _build_content_html(self, context: dict) -> str:
|
||||
"""Build content HTML."""
|
||||
html = CONTENT_TEMPLATE
|
||||
for key, value in context.items():
|
||||
html = html.replace(f"{{{key}}}", str(value))
|
||||
return html
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: Deep Dive v1.0.0 started")
|
||||
|
||||
user_ctx = self._get_user_context(__user__)
|
||||
user_id = user_ctx["user_id"]
|
||||
user_name = user_ctx["user_name"]
|
||||
user_language = user_ctx["user_language"]
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%b %d, %Y %H:%M")
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("No messages found.")
|
||||
|
||||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||||
recent_messages = messages[-message_count:]
|
||||
|
||||
aggregated_parts = []
|
||||
for msg in recent_messages:
|
||||
text = self._extract_text_content(msg.get("content"))
|
||||
if text:
|
||||
aggregated_parts.append(text)
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("No text content found.")
|
||||
|
||||
original_content = "\n\n---\n\n".join(aggregated_parts)
|
||||
word_count = len(original_content.split())
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
msg = f"Content too brief ({len(original_content)} chars). Deep Dive requires at least {self.valves.MIN_TEXT_LENGTH} chars for meaningful analysis."
|
||||
await self._emit_notification(__event_emitter__, msg, "warning")
|
||||
return {"messages": [{"role": "assistant", "content": f"⚠️ {msg}"}]}
|
||||
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "🌊 Initiating Deep Dive thinking process...", "info"
|
||||
)
|
||||
await self._emit_status(
|
||||
__event_emitter__, "🌊 Deep Dive: Analyzing Context & Logic...", False
|
||||
)
|
||||
|
||||
prompt = USER_PROMPT.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
model = self.valves.MODEL_ID or body.get("model")
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
llm_output = response["choices"][0]["message"]["content"]
|
||||
|
||||
processed = self._process_llm_output(llm_output)
|
||||
|
||||
context = {
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"word_count": word_count,
|
||||
**processed,
|
||||
}
|
||||
|
||||
content_html = self._build_content_html(context)
|
||||
|
||||
# Handle existing HTML
|
||||
existing = ""
|
||||
match = re.search(
|
||||
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
||||
original_content,
|
||||
)
|
||||
if match:
|
||||
existing = match.group(1)
|
||||
|
||||
if self.valves.CLEAR_PREVIOUS_HTML or not existing:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
"", content_html, CSS_TEMPLATE, user_language
|
||||
)
|
||||
else:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
existing, content_html, CSS_TEMPLATE, user_language
|
||||
)
|
||||
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n```html\n{final_html}\n```"
|
||||
|
||||
await self._emit_status(__event_emitter__, "🌊 Deep Dive complete!", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"🌊 Deep Dive complete, {user_name}! Thinking chain generated.",
|
||||
"success",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Deep Dive Error: {e}", exc_info=True)
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **Error:** {str(e)}"
|
||||
await self._emit_status(__event_emitter__, "Deep Dive failed.", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, f"Error: {str(e)}", "error"
|
||||
)
|
||||
|
||||
return body
|
||||
BIN
plugins/actions/deep-dive/deep_dive_cn.png
Normal file
|
After Width: | Height: | Size: 997 KiB |
876
plugins/actions/deep-dive/deep_dive_cn.py
Normal file
@@ -0,0 +1,876 @@
|
||||
"""
|
||||
title: 精读
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 1.0.0
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiA3djE0Ii8+PHBhdGggZD0iTTMgMThhMSAxIDAgMCAxLTEtMVY0YTEgMSAwIDAgMSAxLTFoNWE0IDQgMCAwIDEgNCA0IDQgNCAwIDAgMSA0LTRoNWExIDEgMCAwIDEgMSAxdjEzYTEgMSAwIDAgMS0xIDFoLTZhMyAzIDAgMCAwLTMgMyAzIDMgMCAwIDAtMy0zeiIvPjxwYXRoIGQ9Ik02IDEyaDIiLz48cGF0aCBkPSJNMTYgMTJoMiIvPjwvc3ZnPg==
|
||||
requirements: markdown
|
||||
description: 全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||
from datetime import datetime
|
||||
|
||||
# Third-party imports
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
import markdown
|
||||
|
||||
# OpenWebUI imports
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
# Logging setup
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# HTML 模板 - 过程导向设计,支持主题自适应
|
||||
# =================================================================
|
||||
HTML_WRAPPER_TEMPLATE = """
|
||||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
:root {
|
||||
--dd-bg-primary: #ffffff;
|
||||
--dd-bg-secondary: #f8fafc;
|
||||
--dd-bg-tertiary: #f1f5f9;
|
||||
--dd-text-primary: #0f172a;
|
||||
--dd-text-secondary: #334155;
|
||||
--dd-text-dim: #64748b;
|
||||
--dd-border: #e2e8f0;
|
||||
--dd-accent: #3b82f6;
|
||||
--dd-accent-soft: #eff6ff;
|
||||
--dd-header-gradient: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
--dd-shadow: 0 10px 40px rgba(0,0,0,0.06);
|
||||
--dd-code-bg: #f1f5f9;
|
||||
}
|
||||
.theme-dark {
|
||||
--dd-bg-primary: #1e293b;
|
||||
--dd-bg-secondary: #0f172a;
|
||||
--dd-bg-tertiary: #334155;
|
||||
--dd-text-primary: #f1f5f9;
|
||||
--dd-text-secondary: #e2e8f0;
|
||||
--dd-text-dim: #94a3b8;
|
||||
--dd-border: #475569;
|
||||
--dd-accent: #60a5fa;
|
||||
--dd-accent-soft: rgba(59, 130, 246, 0.15);
|
||||
--dd-header-gradient: linear-gradient(135deg, #0f172a 0%, #1e1e2e 100%);
|
||||
--dd-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
--dd-code-bg: #334155;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.plugin-item {
|
||||
background: var(--dd-bg-primary);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--dd-shadow);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dd-border);
|
||||
}
|
||||
/* STYLES_INSERTION_POINT */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<!-- CONTENT_INSERTION_POINT -->
|
||||
</div>
|
||||
<!-- SCRIPTS_INSERTION_POINT -->
|
||||
<script>
|
||||
(function() {
|
||||
const parseColorLuma = (colorStr) => {
|
||||
if (!colorStr) return null;
|
||||
let m = colorStr.match(/^#?([0-9a-f]{6})$/i);
|
||||
if (m) {
|
||||
const hex = m[1];
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
m = colorStr.match(/rgba?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/i);
|
||||
if (m) {
|
||||
const r = parseInt(m[1], 10);
|
||||
const g = parseInt(m[2], 10);
|
||||
const b = parseInt(m[3], 10);
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const getThemeFromMeta = (doc) => {
|
||||
const metas = Array.from((doc || document).querySelectorAll('meta[name="theme-color"]'));
|
||||
if (!metas.length) return null;
|
||||
const color = metas[metas.length - 1].content.trim();
|
||||
const luma = parseColorLuma(color);
|
||||
if (luma === null) return null;
|
||||
return luma < 0.5 ? 'dark' : 'light';
|
||||
};
|
||||
const getParentDocumentSafe = () => {
|
||||
try {
|
||||
if (!window.parent || window.parent === window) return null;
|
||||
const pDoc = window.parent.document;
|
||||
void pDoc.title;
|
||||
return pDoc;
|
||||
} catch (err) { return null; }
|
||||
};
|
||||
const getThemeFromParentClass = () => {
|
||||
try {
|
||||
if (!window.parent || window.parent === window) return null;
|
||||
const pDoc = window.parent.document;
|
||||
const html = pDoc.documentElement;
|
||||
const body = pDoc.body;
|
||||
const htmlClass = html ? html.className : '';
|
||||
const bodyClass = body ? body.className : '';
|
||||
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
||||
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) return 'dark';
|
||||
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) return 'light';
|
||||
return null;
|
||||
} catch (err) { return null; }
|
||||
};
|
||||
const setTheme = () => {
|
||||
const parentDoc = getParentDocumentSafe();
|
||||
const metaTheme = parentDoc ? getThemeFromMeta(parentDoc) : null;
|
||||
const parentClassTheme = getThemeFromParentClass();
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const chosen = metaTheme || parentClassTheme || (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('theme-dark', chosen === 'dark');
|
||||
};
|
||||
setTheme();
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', setTheme);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# LLM 提示词 - 深度下潜思维链
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
你是一位“深度下潜 (Deep Dive)”分析专家。你的目标是引导用户完成一个全面的思维过程,从表面理解深入到战略行动。
|
||||
|
||||
## 思维结构 (严格遵守)
|
||||
|
||||
你必须从以下四个维度剖析输入内容:
|
||||
|
||||
### 1. 🔍 The Context (全景)
|
||||
提供一个高层级的全景视图。内容是关于什么的?核心情境、背景或正在解决的问题是什么?(2-3 段话)
|
||||
|
||||
### 2. 🧠 The Logic (脉络)
|
||||
解构底层结构。论点是如何构建的?其中的推理逻辑、隐藏假设或起作用的思维模型是什么?(列表形式)
|
||||
|
||||
### 3. 💎 The Insight (洞察)
|
||||
提取非显性的价值。有哪些“原来如此”的时刻?揭示了哪些深层含义、盲点或独特视角?(列表形式)
|
||||
|
||||
### 4. 🚀 The Path (路径)
|
||||
定义战略方向。具体的、按优先级排列的下一步行动是什么?如何立即应用这些知识?(可执行步骤)
|
||||
|
||||
## 规则
|
||||
- 使用用户指定的语言输出。
|
||||
- 保持专业、分析性且富有启发性的语调。
|
||||
- 聚焦于“理解的过程”,而不仅仅是结果。
|
||||
- 不要包含寒暄或元对话。
|
||||
"""
|
||||
|
||||
USER_PROMPT = """
|
||||
对以下内容发起“深度下潜”:
|
||||
|
||||
**用户上下文:**
|
||||
- 用户:{user_name}
|
||||
- 时间:{current_date_time_str}
|
||||
- 语言:{user_language}
|
||||
|
||||
**待分析内容:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
请执行完整的思维链:全景 (Context) → 脉络 (Logic) → 洞察 (Insight) → 路径 (Path)。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 现代 CSS 设计 - 深度下潜主题
|
||||
# =================================================================
|
||||
|
||||
CSS_TEMPLATE = """
|
||||
.deep-dive {
|
||||
font-family: 'Inter', -apple-system, system-ui, sans-serif;
|
||||
color: var(--dd-text-secondary);
|
||||
}
|
||||
|
||||
.dd-header {
|
||||
background: var(--dd-header-gradient);
|
||||
padding: 40px 32px;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dd-header-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dd-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dd-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dd-body {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
position: relative;
|
||||
background: var(--dd-bg-primary);
|
||||
}
|
||||
|
||||
/* 思维导火索 */
|
||||
.dd-body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 52px;
|
||||
top: 40px;
|
||||
bottom: 40px;
|
||||
width: 2px;
|
||||
background: var(--dd-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dd-step {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dd-step-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--dd-bg-primary);
|
||||
border: 2px solid var(--dd-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dd-step:hover .dd-step-icon {
|
||||
border-color: var(--dd-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dd-step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dd-step-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dd-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dd-step-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--dd-text-primary);
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.dd-text {
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dd-text p { margin-bottom: 16px; }
|
||||
.dd-text p:last-child { margin-bottom: 0; }
|
||||
|
||||
.dd-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dd-list-item {
|
||||
background: var(--dd-bg-secondary);
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid var(--dd-border);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dd-list-item:hover {
|
||||
background: var(--dd-bg-tertiary);
|
||||
border-left-color: var(--dd-accent);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.dd-list-item strong {
|
||||
color: var(--dd-text-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dd-path-item {
|
||||
background: var(--dd-accent-soft);
|
||||
border-left-color: var(--dd-accent);
|
||||
}
|
||||
|
||||
.dd-footer {
|
||||
padding: 24px 32px;
|
||||
background: var(--dd-bg-secondary);
|
||||
border-top: 1px solid var(--dd-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--dd-text-dim);
|
||||
}
|
||||
|
||||
.dd-tag {
|
||||
padding: 2px 8px;
|
||||
background: var(--dd-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dd-text code,
|
||||
.dd-list-item code {
|
||||
background: var(--dd-code-bg);
|
||||
color: var(--dd-text-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.dd-list-item em {
|
||||
font-style: italic;
|
||||
color: var(--dd-text-dim);
|
||||
}
|
||||
"""
|
||||
|
||||
CONTENT_TEMPLATE = """
|
||||
<div class="deep-dive">
|
||||
<div class="dd-header">
|
||||
<div class="dd-header-badge">思维过程</div>
|
||||
<h1 class="dd-title">精读分析报告</h1>
|
||||
<div class="dd-meta">
|
||||
<span>👤 {user_name}</span>
|
||||
<span>📅 {current_date_time_str}</span>
|
||||
<span>📊 {word_count} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-body">
|
||||
<!-- 第一步:全景 -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">🔍</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 01</div>
|
||||
<h2 class="dd-step-title">全景 (The Context)</h2>
|
||||
<div class="dd-text">{context_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:脉络 -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">🧠</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 02</div>
|
||||
<h2 class="dd-step-title">脉络 (The Logic)</h2>
|
||||
<div class="dd-text">{logic_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:洞察 -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">💎</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 03</div>
|
||||
<h2 class="dd-step-title">洞察 (The Insight)</h2>
|
||||
<div class="dd-text">{insight_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:路径 -->
|
||||
<div class="dd-step">
|
||||
<div class="dd-step-icon">🚀</div>
|
||||
<div class="dd-step-content">
|
||||
<div class="dd-step-label">Phase 04</div>
|
||||
<h2 class="dd-step-title">路径 (The Path)</h2>
|
||||
<div class="dd-text">{path_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-footer">
|
||||
<span>Deep Dive Engine v1.0</span>
|
||||
<span><span class="dd-tag">AI 驱动分析</span></span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True,
|
||||
description="是否显示操作状态更新。",
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="用于分析的 LLM 模型 ID。留空则使用当前模型。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200,
|
||||
description="深度下潜所需的最小文本长度(字符)。",
|
||||
)
|
||||
CLEAR_PREVIOUS_HTML: bool = Field(
|
||||
default=True,
|
||||
description="是否清除之前的插件结果。",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="要分析的最近消息数量。",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""安全提取用户上下文信息。"""
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_data = __user__[0] if __user__ else {}
|
||||
elif isinstance(__user__, dict):
|
||||
user_data = __user__
|
||||
else:
|
||||
user_data = {}
|
||||
|
||||
return {
|
||||
"user_id": user_data.get("id", "unknown_user"),
|
||||
"user_name": user_data.get("name", "用户"),
|
||||
"user_language": user_data.get("language", "zh-CN"),
|
||||
}
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""解析 LLM 输出并转换为样式化 HTML。"""
|
||||
# 使用灵活的正则提取各部分
|
||||
context_match = re.search(
|
||||
r"###\s*1\.\s*🔍?\s*(?:全景|The Context)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
logic_match = re.search(
|
||||
r"###\s*2\.\s*🧠?\s*(?:脉络|The Logic)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
insight_match = re.search(
|
||||
r"###\s*3\.\s*💎?\s*(?:洞察|The Insight)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
path_match = re.search(
|
||||
r"###\s*4\.\s*🚀?\s*(?:路径|The Path)\s*(?:\((.*?)\))?\s*\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 兜底正则
|
||||
if not context_match:
|
||||
context_match = re.search(
|
||||
r"###\s*🔍?\s*(?:全景|The Context).*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not logic_match:
|
||||
logic_match = re.search(
|
||||
r"###\s*🧠?\s*(?:脉络|The Logic).*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not insight_match:
|
||||
insight_match = re.search(
|
||||
r"###\s*💎?\s*(?:洞察|The Insight).*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not path_match:
|
||||
path_match = re.search(
|
||||
r"###\s*🚀?\s*(?:路径|The Path).*?\n(.*?)(?=\n###|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
context_md = (
|
||||
context_match.group(context_match.lastindex).strip()
|
||||
if context_match
|
||||
else ""
|
||||
)
|
||||
logic_md = (
|
||||
logic_match.group(logic_match.lastindex).strip() if logic_match else ""
|
||||
)
|
||||
insight_md = (
|
||||
insight_match.group(insight_match.lastindex).strip()
|
||||
if insight_match
|
||||
else ""
|
||||
)
|
||||
path_md = path_match.group(path_match.lastindex).strip() if path_match else ""
|
||||
|
||||
if not any([context_md, logic_md, insight_md, path_md]):
|
||||
context_md = llm_output.strip()
|
||||
logger.warning("LLM 输出未遵循格式,将作为全景处理。")
|
||||
|
||||
md_extensions = ["nl2br"]
|
||||
|
||||
context_html = (
|
||||
markdown.markdown(context_md, extensions=md_extensions)
|
||||
if context_md
|
||||
else '<p class="dd-no-content">未能提取全景信息。</p>'
|
||||
)
|
||||
logic_html = (
|
||||
self._process_list_items(logic_md, "logic")
|
||||
if logic_md
|
||||
else '<p class="dd-no-content">未能解构脉络。</p>'
|
||||
)
|
||||
insight_html = (
|
||||
self._process_list_items(insight_md, "insight")
|
||||
if insight_md
|
||||
else '<p class="dd-no-content">未能发现洞察。</p>'
|
||||
)
|
||||
path_html = (
|
||||
self._process_list_items(path_md, "path")
|
||||
if path_md
|
||||
else '<p class="dd-no-content">未能定义路径。</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"context_html": context_html,
|
||||
"logic_html": logic_html,
|
||||
"insight_html": insight_html,
|
||||
"path_html": path_html,
|
||||
}
|
||||
|
||||
def _process_list_items(self, md_content: str, section_type: str) -> str:
|
||||
"""将 markdown 列表转换为样式化卡片,支持完整的 markdown 格式。"""
|
||||
lines = md_content.strip().split("\n")
|
||||
items = []
|
||||
current_paragraph = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# 检查列表项(无序或有序)
|
||||
bullet_match = re.match(r"^[-*]\s+(.+)$", line)
|
||||
numbered_match = re.match(r"^\d+\.\s+(.+)$", line)
|
||||
|
||||
if bullet_match or numbered_match:
|
||||
# 清空累积的段落
|
||||
if current_paragraph:
|
||||
para_text = " ".join(current_paragraph)
|
||||
para_html = self._convert_inline_markdown(para_text)
|
||||
items.append(f"<p>{para_html}</p>")
|
||||
current_paragraph = []
|
||||
|
||||
# 提取列表项内容
|
||||
text = (
|
||||
bullet_match.group(1) if bullet_match else numbered_match.group(1)
|
||||
)
|
||||
|
||||
# 处理粗体标题模式:**标题:** 描述 或 **标题**: 描述
|
||||
title_match = re.match(r"\*\*(.+?)\*\*[:\s:]*(.*)$", text)
|
||||
if title_match:
|
||||
title = self._convert_inline_markdown(title_match.group(1))
|
||||
desc = self._convert_inline_markdown(title_match.group(2).strip())
|
||||
path_class = "dd-path-item" if section_type == "path" else ""
|
||||
item_html = f'<div class="dd-list-item {path_class}"><strong>{title}</strong>{desc}</div>'
|
||||
else:
|
||||
text_html = self._convert_inline_markdown(text)
|
||||
path_class = "dd-path-item" if section_type == "path" else ""
|
||||
item_html = (
|
||||
f'<div class="dd-list-item {path_class}">{text_html}</div>'
|
||||
)
|
||||
items.append(item_html)
|
||||
elif line and not line.startswith("#"):
|
||||
# 累积段落文本
|
||||
current_paragraph.append(line)
|
||||
elif not line and current_paragraph:
|
||||
# 空行结束段落
|
||||
para_text = " ".join(current_paragraph)
|
||||
para_html = self._convert_inline_markdown(para_text)
|
||||
items.append(f"<p>{para_html}</p>")
|
||||
current_paragraph = []
|
||||
|
||||
# 清空剩余段落
|
||||
if current_paragraph:
|
||||
para_text = " ".join(current_paragraph)
|
||||
para_html = self._convert_inline_markdown(para_text)
|
||||
items.append(f"<p>{para_html}</p>")
|
||||
|
||||
if items:
|
||||
return f'<div class="dd-list">{" ".join(items)}</div>'
|
||||
return f'<p class="dd-no-content">未找到条目。</p>'
|
||||
|
||||
def _convert_inline_markdown(self, text: str) -> str:
|
||||
"""将行内 markdown(粗体、斜体、代码)转换为 HTML。"""
|
||||
# 转换行内代码:`code` -> <code>code</code>
|
||||
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
||||
# 转换粗体:**text** -> <strong>text</strong>
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
# 转换斜体:*text* -> <em>text</em>(但不在 ** 内部)
|
||||
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
|
||||
return text
|
||||
|
||||
async def _emit_status(
|
||||
self,
|
||||
emitter: Optional[Callable[[Any], Awaitable[None]]],
|
||||
description: str,
|
||||
done: bool = False,
|
||||
):
|
||||
"""发送状态更新事件。"""
|
||||
if self.valves.SHOW_STATUS and emitter:
|
||||
await emitter(
|
||||
{"type": "status", "data": {"description": description, "done": done}}
|
||||
)
|
||||
|
||||
async def _emit_notification(
|
||||
self,
|
||||
emitter: Optional[Callable[[Any], Awaitable[None]]],
|
||||
content: str,
|
||||
ntype: str = "info",
|
||||
):
|
||||
"""发送通知事件。"""
|
||||
if emitter:
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": ntype, "content": content}}
|
||||
)
|
||||
|
||||
def _remove_existing_html(self, content: str) -> str:
|
||||
"""移除已有的插件生成的 HTML。"""
|
||||
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
|
||||
return re.sub(pattern, "", content).strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""从消息内容中提取文本。"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
def _merge_html(
|
||||
self,
|
||||
existing_html: str,
|
||||
new_content: str,
|
||||
new_styles: str = "",
|
||||
user_language: str = "zh-CN",
|
||||
) -> str:
|
||||
"""合并新内容到 HTML 容器。"""
|
||||
if "<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html:
|
||||
base_html = re.sub(r"^```html\s*", "", existing_html)
|
||||
base_html = re.sub(r"\s*```$", "", base_html)
|
||||
else:
|
||||
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
|
||||
|
||||
wrapped = f'<div class="plugin-item">\n{new_content}\n</div>'
|
||||
|
||||
if new_styles:
|
||||
base_html = base_html.replace(
|
||||
"/* STYLES_INSERTION_POINT */",
|
||||
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
||||
)
|
||||
|
||||
base_html = base_html.replace(
|
||||
"<!-- CONTENT_INSERTION_POINT -->",
|
||||
f"{wrapped}\n<!-- CONTENT_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def _build_content_html(self, context: dict) -> str:
|
||||
"""构建内容 HTML。"""
|
||||
html = CONTENT_TEMPLATE
|
||||
for key, value in context.items():
|
||||
html = html.replace(f"{{{key}}}", str(value))
|
||||
return html
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 精读 v1.0.0 启动")
|
||||
|
||||
user_ctx = self._get_user_context(__user__)
|
||||
user_id = user_ctx["user_id"]
|
||||
user_name = user_ctx["user_name"]
|
||||
user_language = user_ctx["user_language"]
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M")
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("未找到消息内容。")
|
||||
|
||||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||||
recent_messages = messages[-message_count:]
|
||||
|
||||
aggregated_parts = []
|
||||
for msg in recent_messages:
|
||||
text = self._extract_text_content(msg.get("content"))
|
||||
if text:
|
||||
aggregated_parts.append(text)
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("未找到文本内容。")
|
||||
|
||||
original_content = "\n\n---\n\n".join(aggregated_parts)
|
||||
word_count = len(original_content)
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
msg = f"内容过短({len(original_content)} 字符)。精读至少需要 {self.valves.MIN_TEXT_LENGTH} 字符才能进行有意义的分析。"
|
||||
await self._emit_notification(__event_emitter__, msg, "warning")
|
||||
return {"messages": [{"role": "assistant", "content": f"⚠️ {msg}"}]}
|
||||
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "📖 正在发起精读分析...", "info"
|
||||
)
|
||||
await self._emit_status(
|
||||
__event_emitter__, "📖 精读:正在分析全景与脉络...", False
|
||||
)
|
||||
|
||||
prompt = USER_PROMPT.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
model = self.valves.MODEL_ID or body.get("model")
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"未找到用户:{user_id}")
|
||||
|
||||
response = await generate_chat_completion(__request__, payload, user_obj)
|
||||
llm_output = response["choices"][0]["message"]["content"]
|
||||
|
||||
processed = self._process_llm_output(llm_output)
|
||||
|
||||
context = {
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"word_count": word_count,
|
||||
**processed,
|
||||
}
|
||||
|
||||
content_html = self._build_content_html(context)
|
||||
|
||||
# 处理已有 HTML
|
||||
existing = ""
|
||||
match = re.search(
|
||||
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
||||
original_content,
|
||||
)
|
||||
if match:
|
||||
existing = match.group(1)
|
||||
|
||||
if self.valves.CLEAR_PREVIOUS_HTML or not existing:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
"", content_html, CSS_TEMPLATE, user_language
|
||||
)
|
||||
else:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
existing, content_html, CSS_TEMPLATE, user_language
|
||||
)
|
||||
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n```html\n{final_html}\n```"
|
||||
|
||||
await self._emit_status(__event_emitter__, "📖 精读完成!", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"📖 精读完成,{user_name}!思维链已生成。",
|
||||
"success",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Deep Dive 错误:{e}", exc_info=True)
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **错误:** {str(e)}"
|
||||
await self._emit_status(__event_emitter__, "精读失败。", True)
|
||||
await self._emit_notification(__event_emitter__, f"错误:{str(e)}", "error")
|
||||
|
||||
return body
|
||||
BIN
plugins/actions/export_to_docx/export_to_word.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
plugins/actions/export_to_docx/export_to_word_cn.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
@@ -1,11 +1,14 @@
|
||||
# 📊 Smart Infographic (AntV)
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.4.1 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.4.9 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
An Open WebUI plugin powered by the AntV Infographic engine. It transforms long text into professional, beautiful infographics with a single click.
|
||||
|
||||
## 🔥 What's New in v1.4.1
|
||||
## 🔥 What's New in v1.4.9
|
||||
|
||||
- 🎨 **70+ Official Templates**: Integrated comprehensive AntV infographic template library.
|
||||
- 🖼️ **Iconify & unDraw Support**: Richer visuals with official icons and illustrations.
|
||||
- 📏 **Visual Optimization**: Improved text wrapping, adaptive sizing, and layout refinement.
|
||||
- ✨ **PNG Upload**: Infographics now upload as PNG format for better Word export compatibility.
|
||||
- 🔧 **Canvas Conversion**: Uses browser canvas for high-quality SVG to PNG conversion (2x scale).
|
||||
|
||||
@@ -17,8 +20,8 @@ An Open WebUI plugin powered by the AntV Infographic engine. It transforms long
|
||||
## ✨ Key Features
|
||||
|
||||
- 🚀 **AI-Powered Transformation**: Automatically analyzes text logic, extracts key points, and generates structured charts.
|
||||
- 🎨 **Professional Templates**: Includes various AntV official templates: Lists, Trees, Mindmaps, Comparison Tables, Flowcharts, and Statistical Charts.
|
||||
- 🔍 **Auto-Icon Matching**: Built-in logic to search and match the most relevant Material Design Icons based on content.
|
||||
- 🎨 **70+ Professional Templates**: Includes various AntV official templates: Lists, Trees, Roadmaps, Timelines, Comparison Tables, SWOT, Quadrants, and Statistical Charts.
|
||||
- 🔍 **Auto-Icon Matching**: Built-in logic to search and match the most relevant icons (Iconify) and illustrations (unDraw).
|
||||
- 📥 **Multi-Format Export**: Download your infographics as **SVG**, **PNG**, or a **Standalone HTML** file.
|
||||
- 🌈 **Highly Customizable**: Supports Dark/Light modes, auto-adapts theme colors, with bold titles and refined card layouts.
|
||||
- 📱 **Responsive Design**: Generated charts look great on both desktop and mobile devices.
|
||||
@@ -47,10 +50,11 @@ You can adjust the following parameters in the plugin settings to optimize the g
|
||||
|
||||
| Category | Template Name | Use Case |
|
||||
| :--- | :--- | :--- |
|
||||
| **Lists & Hierarchy** | `list-grid`, `tree-vertical`, `mindmap` | Features, Org Charts, Brainstorming |
|
||||
| **Sequence & Relation** | `sequence-roadmap`, `relation-circle` | Roadmaps, Circular Flows, Steps |
|
||||
| **Comparison & Analysis** | `compare-binary`, `compare-swot`, `quadrant-quarter` | Pros/Cons, SWOT, Quadrants |
|
||||
| **Charts & Data** | `chart-bar`, `chart-line`, `chart-pie` | Trends, Distributions, Metrics |
|
||||
| **Sequence** | `sequence-timeline-simple`, `sequence-roadmap-vertical-simple`, `sequence-snake-steps-compact-card` | Timelines, Roadmaps, Processes |
|
||||
| **Lists** | `list-grid-candy-card-lite`, `list-row-horizontal-icon-arrow`, `list-column-simple-vertical-arrow` | Features, Bullet Points, Lists |
|
||||
| **Comparison** | `compare-binary-horizontal-underline-text-vs`, `compare-swot`, `quadrant-quarter-simple-card` | Pros/Cons, SWOT, Quadrants |
|
||||
| **Hierarchy** | `hierarchy-tree-tech-style-capsule-item`, `hierarchy-structure` | Org Charts, Structures |
|
||||
| **Charts** | `chart-column-simple`, `chart-bar-plain-text`, `chart-line-plain-text`, `chart-wordcloud` | Trends, Distributions, Metrics |
|
||||
|
||||
## 📝 Syntax Example (For Advanced Users)
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# 📊 智能信息图 (AntV Infographic)
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.4.1 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.4.9 | **Project:** [Awesome OpenWebUI](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
基于 AntV Infographic 引擎的 Open WebUI 插件,能够将长文本内容一键转换为专业、美观的信息图表。
|
||||
|
||||
## 🔥 v1.4.1 更新日志
|
||||
## 🔥 v1.4.9 更新日志
|
||||
|
||||
- 🎨 **70+ 官方模板**:全面集成 AntV 官方信息图模板库。
|
||||
- 🖼️ **图标与插图支持**:支持 Iconify 图标库与 unDraw 插图库,视觉效果更丰富。
|
||||
- 📏 **视觉优化**:改进文本换行逻辑,优化自适应尺寸,提升卡片布局精细度。
|
||||
- ✨ **PNG 上传**:信息图现在以 PNG 格式上传,与 Word 导出完美兼容。
|
||||
- 🔧 **Canvas 转换**:使用浏览器 Canvas 高质量转换 SVG 为 PNG(2倍缩放)。
|
||||
|
||||
@@ -17,8 +20,8 @@
|
||||
## ✨ 核心特性
|
||||
|
||||
- 🚀 **智能转换**:自动分析文本核心逻辑,提取关键点并生成结构化图表。
|
||||
- 🎨 **专业模板**:内置多种 AntV 官方模板,包括列表、树图、思维导图、对比图、流程图及统计图表等。
|
||||
- 🔍 **自动图标匹配**:内置图标搜索逻辑,根据内容自动匹配最相关的 Material Design Icons。
|
||||
- 🎨 **70+ 专业模板**:内置多种 AntV 官方模板,包括列表、树图、路线图、时间线、对比图、SWOT、象限图及统计图表等。
|
||||
- 🔍 **自动图标匹配**:内置图标搜索逻辑,支持 Iconify 图标和 unDraw 插图自动匹配。
|
||||
- 📥 **多格式导出**:支持一键下载为 **SVG**、**PNG** 或 **独立 HTML** 文件。
|
||||
- 🌈 **高度自定义**:支持深色/浅色模式,自动适配主题颜色,主标题加粗突出,卡片布局精美。
|
||||
- 📱 **响应式设计**:生成的图表在桌面端和移动端均有良好的展示效果。
|
||||
@@ -47,10 +50,11 @@
|
||||
|
||||
| 分类 | 模板名称 | 适用场景 |
|
||||
| :--- | :--- | :--- |
|
||||
| **列表与层级** | `list-grid`, `tree-vertical`, `mindmap` | 功能亮点、组织架构、思维导图 |
|
||||
| **顺序与关系** | `sequence-roadmap`, `relation-circle` | 发展历程、循环关系、步骤说明 |
|
||||
| **对比与分析** | `compare-binary`, `compare-swot`, `quadrant-quarter` | 优劣势对比、SWOT 分析、象限图 |
|
||||
| **图表与数据** | `chart-bar`, `chart-line`, `chart-pie` | 数据趋势、比例分布、数值对比 |
|
||||
| **时序与流程** | `sequence-timeline-simple`, `sequence-roadmap-vertical-simple`, `sequence-snake-steps-compact-card` | 时间线、路线图、步骤说明 |
|
||||
| **列表与网格** | `list-grid-candy-card-lite`, `list-row-horizontal-icon-arrow`, `list-column-simple-vertical-arrow` | 功能亮点、要点列举、清单 |
|
||||
| **对比与分析** | `compare-binary-horizontal-underline-text-vs`, `compare-swot`, `quadrant-quarter-simple-card` | 优劣势对比、SWOT 分析、象限图 |
|
||||
| **层级与结构** | `hierarchy-tree-tech-style-capsule-item`, `hierarchy-structure` | 组织架构、层级关系 |
|
||||
| **图表与数据** | `chart-column-simple`, `chart-bar-plain-text`, `chart-line-plain-text`, `chart-wordcloud` | 数据趋势、比例分布、数值对比 |
|
||||
|
||||
## 📝 语法示例 (高级用户)
|
||||
|
||||
|
||||
BIN
plugins/actions/infographic/infographic.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
title: 📊 Smart Infographic (AntV)
|
||||
author: jeff
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
|
||||
version: 1.4.1
|
||||
version: 1.4.9
|
||||
openwebui_id: ad6f0c7f-c571-4dea-821d-8e71697274cf
|
||||
description: AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.
|
||||
"""
|
||||
@@ -47,24 +47,63 @@ Infographic syntax is a Mermaid-like declarative syntax for describing infograph
|
||||
|
||||
### Template Library & Selection Guide
|
||||
|
||||
Choose the most appropriate template based on the content structure:
|
||||
Choose the most appropriate template based on content structure.
|
||||
|
||||
#### 1. List & Hierarchy
|
||||
- **List**: `list-grid` (Grid Cards), `list-vertical` (Vertical List)
|
||||
- **Tree**: `tree-vertical` (Vertical Tree), `tree-horizontal` (Horizontal Tree)
|
||||
- **Mindmap**: `mindmap` (Mind Map)
|
||||
**Template Selection Guidelines (Official):**
|
||||
- Strict sequential order (processes/steps/trends) → `sequence-*` series
|
||||
- Timeline → `sequence-timeline-simple`
|
||||
- Roadmap → `sequence-roadmap-vertical-simple`
|
||||
- Zigzag steps → `sequence-horizontal-zigzag-underline-text`
|
||||
- Snake steps → `sequence-snake-steps-compact-card`
|
||||
- Listing viewpoints → `list-row-horizontal-icon-arrow` or `list-column-simple-vertical-arrow`
|
||||
- Comparative analysis (A vs B) → `compare-binary-horizontal-underline-text-vs`
|
||||
- SWOT analysis → `compare-swot`
|
||||
- Hierarchical structure (tree) → `hierarchy-tree-tech-style-capsule-item`
|
||||
- Data charts → `chart-*` series
|
||||
- Quadrant analysis → `quadrant-quarter-simple-card`
|
||||
- Grid lists (bullet points) → `list-grid-candy-card-lite`
|
||||
- Relationship display → `relation-circle-icon-badge`
|
||||
|
||||
#### 2. Sequence & Relationship
|
||||
- **Process**: `sequence-roadmap` (Roadmap), `sequence-zigzag` (Zigzag Process), `sequence-horizontal` (Horizontal Process)
|
||||
- **Relationship**: `relation-sankey` (Sankey Diagram), `relation-circle` (Circular Relationship)
|
||||
**Available Templates:**
|
||||
|
||||
#### 3. Comparison & Analysis
|
||||
- **Comparison**: `compare-binary` (Binary Comparison), `list-grid` (Multi-item Grid Comparison)
|
||||
- **Analysis**: `compare-swot` (SWOT Analysis), `quadrant-quarter` (Quadrant Chart)
|
||||
*Sequence (时序/流程):*
|
||||
`sequence-timeline-simple`, `sequence-roadmap-vertical-simple`, `sequence-horizontal-zigzag-underline-text`,
|
||||
`sequence-snake-steps-compact-card`, `sequence-zigzag-steps-underline-text`, `sequence-circular-simple`,
|
||||
`sequence-pyramid-simple`, `sequence-ascending-steps`
|
||||
|
||||
#### 4. Charts & Data
|
||||
- **Statistics**: `statistic-card` (Statistic Cards)
|
||||
- **Charts**: `chart-bar` (Bar Chart), `chart-column` (Column Chart), `chart-line` (Line Chart), `chart-pie` (Pie Chart), `chart-doughnut` (Doughnut Chart), `chart-area` (Area Chart)
|
||||
*List (列表):*
|
||||
`list-grid-candy-card-lite`, `list-grid-badge-card`, `list-row-horizontal-icon-arrow`,
|
||||
`list-column-simple-vertical-arrow`, `list-column-done-list`
|
||||
|
||||
*Compare (对比):*
|
||||
`compare-binary-horizontal-underline-text-vs`, `compare-binary-horizontal-simple-fold`,
|
||||
`compare-hierarchy-left-right-circle-node-pill-badge`, `compare-swot`
|
||||
|
||||
*Hierarchy (层级):*
|
||||
`hierarchy-tree-tech-style-capsule-item`, `hierarchy-tree-curved-line-rounded-rect-node`, `hierarchy-structure`
|
||||
|
||||
*Chart (图表):*
|
||||
`chart-column-simple`, `chart-bar-plain-text`, `chart-line-plain-text`,
|
||||
`chart-pie-plain-text`, `chart-pie-donut-plain-text`, `chart-wordcloud`
|
||||
|
||||
*Other:*
|
||||
`quadrant-quarter-simple-card`, `relation-circle-icon-badge`
|
||||
|
||||
**Text Capacity by Template Type:**
|
||||
- HIGH capacity (long descriptions OK): `list-column-*`, `compare-binary-*`, `sequence-timeline-*`
|
||||
- MEDIUM capacity: `list-row-*`, `sequence-roadmap-*`
|
||||
- LOW capacity (short text only): `list-grid-*`, `hierarchy-*`, `sequence-steps`
|
||||
|
||||
### Icon and Illustration Resources
|
||||
|
||||
**Icons (Iconify):**
|
||||
- Format: `<collection>/<icon-name>`, e.g., `mdi/rocket-launch`
|
||||
- Popular: `mdi/*` (Material Design), `fa/*` (Font Awesome), `bi/*` (Bootstrap)
|
||||
- Examples: `mdi/code-tags`, `mdi/chart-line`, `mdi/account-group`, `mdi/cloud`
|
||||
|
||||
**Illustrations (unDraw):**
|
||||
- Format: filename without .svg, e.g., `coding`, `team-work`
|
||||
- Use `illus` field instead of `icon`
|
||||
|
||||
### Data Structure Examples
|
||||
|
||||
@@ -211,6 +250,12 @@ data
|
||||
- `children`: Nested items (for trees, SWOT, etc.)
|
||||
- `illus`: Illustration icon (specific to some templates like Quadrant)
|
||||
|
||||
### Content Refinement Principles
|
||||
1. **Brevity is King**: Infographics are visual. Keep text to a minimum.
|
||||
2. **Title Limit**: Keep `label` (item titles) under 15 characters (approx. 10 Chinese characters).
|
||||
3. **Description Limit**: Keep `desc` (item descriptions) under 40 characters (approx. 20 Chinese characters / 2 lines).
|
||||
4. **Impact**: Use strong verbs and nouns. Avoid filler words.
|
||||
|
||||
## Output Requirements
|
||||
1. **Language**: Output content in the user's language.
|
||||
2. **Format**: Wrap output in ```infographic ... ```.
|
||||
@@ -233,9 +278,18 @@ User Language: {user_language}
|
||||
|
||||
Please select the most appropriate infographic template based on text characteristics and output standard infographic syntax. Pay attention to correct indentation format (two spaces).
|
||||
|
||||
**Important Note:**
|
||||
- If using `list-grid` format, ensure each card's `desc` description is limited to **maximum 30 Chinese characters** (or **approximately 60 English characters**) to maintain visual consistency with all descriptions fitting in 2 lines.
|
||||
- Descriptions should be concise and highlight key points.
|
||||
**Visual Optimization Guide (MUST FOLLOW):**
|
||||
- **Point-based Generation:** Infographics are not articles. Extract KEYWORDS ONLY, avoid complete sentences.
|
||||
- **Main Title (`data.title`):** **MUST** be ≤ **15 Chinese characters** (or ≤30 English characters). Trim version numbers or details if needed.
|
||||
- **Subtitle (`data.desc`):** **MUST** be ≤ **20 Chinese characters** (or ≤40 English characters).
|
||||
- **Card Title (`label`):** **MUST** be ≤ **6 Chinese characters** (or ≤12 English characters). Use 2-4 keywords only.
|
||||
- **Card Description (`desc`):** **MUST** be ≤ **12 Chinese characters** (or ≤24 English characters). Use short phrases.
|
||||
|
||||
⚠️ **CRITICAL**: If the original text is too long, you MUST rephrase and shorten it. Do NOT simply truncate with "...".
|
||||
Examples:
|
||||
- ❌ "多步任务与工具协作能力" → ✅ "多步任务协作"
|
||||
- ❌ "Open WebUI v0.7.x 重大版本更新" → ✅ "v0.7 核心更新"
|
||||
- ❌ "自动查找历史聊天记录" → ✅ "历史检索"
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
@@ -340,8 +394,9 @@ CSS_TEMPLATE_INFOGRAPHIC = """
|
||||
.infographic-container-wrapper .infographic-render-container {
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
min-height: 600px;
|
||||
background: #fff;
|
||||
overflow: visible; /* Ensure content is visible */
|
||||
overflow: visible;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.infographic-render-container svg text {
|
||||
@@ -349,35 +404,59 @@ CSS_TEMPLATE_INFOGRAPHIC = """
|
||||
}
|
||||
.infographic-render-container svg foreignObject {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
|
||||
line-height: 1.4 !important;
|
||||
line-height: 1.3 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
/* Main title styles */
|
||||
.infographic-render-container svg foreignObject[data-element-type="title"] > * {
|
||||
font-size: 1.5em !important;
|
||||
font-weight: bold !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: nowrap !important;
|
||||
font-size: 1.3em !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
/* Page subtitle and card title styles */
|
||||
.infographic-render-container svg foreignObject[data-element-type="desc"] > *,
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
|
||||
font-size: 0.6em !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
/* Card title with extra bottom spacing */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
|
||||
padding-bottom: 8px !important;
|
||||
/* Page subtitle styles */
|
||||
.infographic-render-container svg foreignObject[data-element-type="desc"] > * {
|
||||
font-size: 0.85em !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
overflow: visible !important;
|
||||
text-align: center !important;
|
||||
display: block !important;
|
||||
color: var(--ig-muted-text-color) !important;
|
||||
}
|
||||
/* Card description text keeps normal wrapping */
|
||||
/* Card title styles */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
|
||||
font-size: 0.9em !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
/* Card description text */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-desc"] > * {
|
||||
font-size: 0.8em !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
.infographic-container-wrapper .download-area {
|
||||
text-align: center;
|
||||
@@ -533,37 +612,41 @@ SCRIPT_TEMPLATE_INFOGRAPHIC = """
|
||||
}}
|
||||
}}
|
||||
|
||||
// 2. Template Mapping Configuration
|
||||
// 2. Template Mapping Configuration (Official AntV Structure IDs)
|
||||
const TEMPLATE_MAPPING = {{
|
||||
// List & Hierarchy
|
||||
// List & Hierarchy - map short names to full template names
|
||||
'list-grid': 'list-grid-compact-card',
|
||||
'list-column': 'list-column-simple-vertical-arrow',
|
||||
'list-row': 'list-row-simple-horizontal-arrow',
|
||||
'hierarchy-tree': 'hierarchy-tree-tech-style-capsule-item',
|
||||
|
||||
// Sequence & Timeline
|
||||
'sequence-roadmap-vertical': 'sequence-roadmap-vertical-simple',
|
||||
'sequence-timeline': 'sequence-timeline-simple',
|
||||
'sequence-steps': 'sequence-steps-simple',
|
||||
'sequence-horizontal-zigzag': 'sequence-horizontal-zigzag-simple',
|
||||
|
||||
// Comparison
|
||||
'compare-binary-horizontal': 'compare-binary-horizontal-simple-vs',
|
||||
'compare-hierarchy-row': 'compare-hierarchy-row-simple',
|
||||
|
||||
// Charts
|
||||
'chart-column': 'chart-column-simple',
|
||||
'quadrant': 'quadrant-quarter-simple-card',
|
||||
|
||||
// Legacy mappings for backward compatibility
|
||||
'list-vertical': 'list-column-simple-vertical-arrow',
|
||||
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
|
||||
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
|
||||
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
|
||||
|
||||
// Sequence & Relationship
|
||||
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
|
||||
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
|
||||
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
|
||||
'relation-sankey': 'relation-sankey-simple',
|
||||
'relation-circle': 'relation-circle-icon-badge',
|
||||
|
||||
// Comparison & Analysis
|
||||
'compare-binary': 'compare-binary-horizontal-simple-vs',
|
||||
'compare-swot': 'compare-swot',
|
||||
'quadrant-quarter': 'quadrant-quarter-simple-card',
|
||||
|
||||
// Charts & Data
|
||||
'statistic-card': 'list-grid-compact-card',
|
||||
'chart-bar': 'chart-bar-plain-text',
|
||||
'chart-column': 'chart-column-simple',
|
||||
'chart-line': 'chart-line-plain-text',
|
||||
'chart-area': 'chart-area-simple',
|
||||
'chart-pie': 'chart-pie-plain-text',
|
||||
'chart-doughnut': 'chart-pie-donut-plain-text'
|
||||
}};
|
||||
|
||||
|
||||
// 3. Apply Mapping Strategy
|
||||
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
|
||||
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
|
||||
@@ -629,10 +712,48 @@ SCRIPT_TEMPLATE_INFOGRAPHIC = """
|
||||
containerEl.dataset.infographicRendered = 'true';
|
||||
console.log('[Infographic] Rendering complete');
|
||||
|
||||
// Auto-adjust height
|
||||
// Auto-adjust height and tag elements
|
||||
setTimeout(() => {
|
||||
const svg = containerEl.querySelector('svg');
|
||||
if (svg) {
|
||||
// 1. Tag elements for CSS styling
|
||||
const fos = Array.from(svg.querySelectorAll('foreignObject'));
|
||||
let titleFound = false;
|
||||
let descFound = false;
|
||||
|
||||
fos.forEach((fo) => {
|
||||
const text = fo.textContent.trim();
|
||||
if (!text || fo.querySelector('i') || (fo.querySelector('svg') && fo.querySelectorAll('*').length < 5)) {
|
||||
fo.setAttribute('data-element-type', 'icon');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamically increase height and width to accommodate wrapped text
|
||||
const currentHeight = parseInt(fo.getAttribute('height') || '0');
|
||||
if (currentHeight > 0 && currentHeight < 200) {
|
||||
fo.setAttribute('height', Math.round(currentHeight * 1.8).toString());
|
||||
}
|
||||
const currentWidth = parseInt(fo.getAttribute('width') || '0');
|
||||
if (currentWidth > 0 && currentWidth < 300) {
|
||||
fo.setAttribute('width', Math.max(Math.round(currentWidth * 1.2), 180).toString());
|
||||
}
|
||||
|
||||
if (!titleFound) {
|
||||
fo.setAttribute('data-element-type', 'title');
|
||||
titleFound = true;
|
||||
} else if (!descFound) {
|
||||
fo.setAttribute('data-element-type', 'desc');
|
||||
descFound = true;
|
||||
} else {
|
||||
if (fo.querySelector('strong') || fo.style.fontWeight === 'bold' || text.length < 15) {
|
||||
fo.setAttribute('data-element-type', 'item-label');
|
||||
} else {
|
||||
fo.setAttribute('data-element-type', 'item-desc');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Adjust height
|
||||
const bbox = svg.getBoundingClientRect();
|
||||
let contentHeight = bbox.height;
|
||||
if (svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.height) {
|
||||
|
||||
BIN
plugins/actions/infographic/infographic_cn.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
title: 📊 智能信息图 (AntV Infographic)
|
||||
author: jeff
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
|
||||
version: 1.4.1
|
||||
version: 1.4.9
|
||||
openwebui_id: e04a48ff-23ee-4a41-8ea7-66c19524e7c8
|
||||
description: 基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。
|
||||
"""
|
||||
@@ -45,32 +45,61 @@ Infographic syntax is a Mermaid-like declarative syntax for describing infograph
|
||||
- ❌ Wrong: `children:` `items:` `data:` (with colons)
|
||||
- ✅ Correct: `children` `items` `data` (without colons)
|
||||
|
||||
### Template Library & Selection Guide
|
||||
### 模板库与选择指南
|
||||
|
||||
#### 1. List & Hierarchy (Text-heavy)
|
||||
- **Linear & Short (Steps/Phases)** -> `list-row-horizontal-icon-arrow`
|
||||
- **Linear & Long (Rankings/Details)** -> `list-vertical`
|
||||
- **Grouped / Parallel (Features/Catalog)** -> `list-grid`
|
||||
- **Hierarchical (Org Chart/Taxonomy)** -> `tree-vertical` or `tree-horizontal`
|
||||
- **Central Idea (Brainstorming)** -> `mindmap`
|
||||
根据内容结构选择最合适的模板。
|
||||
|
||||
#### 2. Sequence & Relationship (Flow-based)
|
||||
- **Time-based (History/Plan)** -> `sequence-roadmap-vertical-simple`
|
||||
- **Process Flow (Complex)** -> `sequence-zigzag` or `sequence-horizontal`
|
||||
- **Resource Flow / Distribution** -> `relation-sankey`
|
||||
- **Circular Relationship** -> `relation-circle`
|
||||
**模板选择指南 (官方):**
|
||||
- 严格时序 (流程/步骤/趋势) → `sequence-*` 系列
|
||||
- 时间线 → `sequence-timeline-simple`
|
||||
- 路线图 → `sequence-roadmap-vertical-simple`
|
||||
- 折线步骤 → `sequence-horizontal-zigzag-underline-text`
|
||||
- 蛇形步骤 → `sequence-snake-steps-compact-card`
|
||||
- 列举要点 → `list-row-horizontal-icon-arrow` 或 `list-column-simple-vertical-arrow`
|
||||
- 对比分析 (A vs B) → `compare-binary-horizontal-underline-text-vs`
|
||||
- SWOT 分析 → `compare-swot`
|
||||
- 层级结构 (树状图) → `hierarchy-tree-tech-style-capsule-item`
|
||||
- 数据图表 → `chart-*` 系列
|
||||
- 象限分析 → `quadrant-quarter-simple-card`
|
||||
- 网格列表 → `list-grid-candy-card-lite`
|
||||
- 关系展示 → `relation-circle-icon-badge`
|
||||
|
||||
#### 3. Comparison & Analysis
|
||||
- **Binary Comparison (A vs B)** -> `compare-binary`
|
||||
- **SWOT Analysis** -> `compare-swot`
|
||||
- **Quadrant Analysis (Importance vs Urgency)** -> `quadrant-quarter`
|
||||
- **Multi-item Grid Comparison** -> `list-grid` (use for comparing multiple items)
|
||||
**可用模板:**
|
||||
|
||||
#### 4. Charts & Data (Metric-heavy)
|
||||
- **Key Metrics / Data Cards** -> `statistic-card`
|
||||
- **Distribution / Comparison** -> `chart-bar` or `chart-column`
|
||||
- **Trend over Time** -> `chart-line` or `chart-area`
|
||||
- **Proportion / Part-to-Whole** -> `chart-pie` or `chart-doughnut`
|
||||
*Sequence (时序/流程):*
|
||||
`sequence-timeline-simple`, `sequence-roadmap-vertical-simple`, `sequence-horizontal-zigzag-underline-text`,
|
||||
`sequence-snake-steps-compact-card`, `sequence-zigzag-steps-underline-text`, `sequence-circular-simple`
|
||||
|
||||
*List (列表):*
|
||||
`list-grid-candy-card-lite`, `list-grid-badge-card`, `list-row-horizontal-icon-arrow`,
|
||||
`list-column-simple-vertical-arrow`, `list-column-done-list`
|
||||
|
||||
*Compare (对比):*
|
||||
`compare-binary-horizontal-underline-text-vs`, `compare-swot`
|
||||
|
||||
*Hierarchy (层级):*
|
||||
`hierarchy-tree-tech-style-capsule-item`, `hierarchy-structure`
|
||||
|
||||
*Chart (图表):*
|
||||
`chart-column-simple`, `chart-bar-plain-text`, `chart-pie-plain-text`, `chart-wordcloud`
|
||||
|
||||
*Other:*
|
||||
`quadrant-quarter-simple-card`, `relation-circle-icon-badge`
|
||||
|
||||
**按容量分类:**
|
||||
- 高容量 (长描述): `list-column-*`, `compare-binary-*`, `sequence-timeline-*`
|
||||
- 中容量: `list-row-*`, `sequence-roadmap-*`
|
||||
- 低容量 (短文本): `list-grid-*`, `hierarchy-*`
|
||||
|
||||
### 图标和插图资源
|
||||
|
||||
**图标 (Iconify):**
|
||||
- 格式: `<集合>/<图标名>`, 如 `mdi/rocket-launch`
|
||||
- 常用: `mdi/*`, `fa/*`, `bi/*`
|
||||
|
||||
**插图 (unDraw):**
|
||||
- 格式: 文件名 (不含 .svg), 如 `coding`, `team-work`
|
||||
- 使用 `illus` 字段
|
||||
|
||||
### Infographic Syntax Guide
|
||||
|
||||
@@ -203,6 +232,12 @@ data
|
||||
desc Plan for next sprint
|
||||
illus mdi/star
|
||||
|
||||
### Content Refinement Principles
|
||||
1. **Brevity is King**: Infographics are visual. Keep text to a minimum.
|
||||
2. **Title Limit**: Keep `label` (item titles) under 15 characters.
|
||||
3. **Description Limit**: Keep `desc` (item descriptions) under 25 characters (approx. 2 lines).
|
||||
4. **Impact**: Use strong verbs and nouns. Avoid filler words.
|
||||
|
||||
### Output Rules
|
||||
1. **Strict Syntax**: Follow the indentation and formatting rules exactly.
|
||||
2. **No Explanations**: Output ONLY the syntax code block.
|
||||
@@ -224,9 +259,11 @@ USER_PROMPT_GENERATE_INFOGRAPHIC = """
|
||||
|
||||
请根据文本特点选择最合适的信息图模板,并输出规范的 infographic 语法。注意保持正确的缩进格式(两个空格)。
|
||||
|
||||
**重要提示:**
|
||||
- 如果使用 `list-grid` 格式,请确保每个卡片的 `desc` 描述文字控制在 **30个汉字**(或约60个英文字符)**以内**,以保证所有卡片描述都只占用2行,维持视觉一致性。
|
||||
- 描述应简洁精炼,突出核心要点。
|
||||
**视觉优化指南:**
|
||||
- **要点化生成:** 信息图不是文章。请将内容转化为“关键词+短语”的形式,严禁生成长难句。
|
||||
- **标题限制:** 每个卡片的 `label`(标题)请控制在 **8个汉字**以内。
|
||||
- **描述限制:** 每个卡片的 `desc`(描述)请控制在 **15个汉字**以内,确保即使在小屏幕上也能完整显示。
|
||||
- **结构化思维:** 优先使用并列、递进或对比结构,使信息一目了然。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
@@ -333,7 +370,7 @@ CSS_TEMPLATE_INFOGRAPHIC = """
|
||||
padding: 16px;
|
||||
min-height: 600px;
|
||||
background: #fff;
|
||||
overflow: visible; /* Ensure content is visible */
|
||||
overflow: visible;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.infographic-render-container svg text {
|
||||
@@ -341,35 +378,58 @@ CSS_TEMPLATE_INFOGRAPHIC = """
|
||||
}
|
||||
.infographic-render-container svg foreignObject {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif !important;
|
||||
line-height: 1.4 !important;
|
||||
line-height: 1.3 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
/* 主标题样式 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="title"] > * {
|
||||
font-size: 1.5em !important;
|
||||
font-weight: bold !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
/* 页面副标题和卡片标题样式 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="desc"] > *,
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
|
||||
font-size: 0.6em !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
/* 卡片标题额外增加底部间距 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
|
||||
padding-bottom: 8px !important;
|
||||
display: block !important;
|
||||
}
|
||||
/* 卡片描述文字保持正常换行 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-desc"] > * {
|
||||
line-height: 1.4 !important;
|
||||
font-size: 1.3em !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
/* 页面副标题样式 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="desc"] > * {
|
||||
font-size: 0.85em !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
text-align: center !important;
|
||||
display: block !important;
|
||||
color: var(--ig-muted-text-color) !important;
|
||||
}
|
||||
/* 卡片标题样式 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-label"] > * {
|
||||
font-size: 0.9em !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: normal !important;
|
||||
word-break: break-word !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
/* 卡片描述文字 */
|
||||
.infographic-render-container svg foreignObject[data-element-type="item-desc"] > * {
|
||||
font-size: 0.82em !important;
|
||||
line-height: 1.3 !important;
|
||||
white-space: normal !important;
|
||||
display: -webkit-box !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
-webkit-box-orient: vertical !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
.infographic-container-wrapper .download-area {
|
||||
text-align: center;
|
||||
@@ -537,34 +597,36 @@ SCRIPT_TEMPLATE_INFOGRAPHIC = """
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 模板映射配置
|
||||
// 2. 模板映射配置
|
||||
// 2. 模板映射配置 (官方 AntV 结构 ID)
|
||||
const TEMPLATE_MAPPING = {
|
||||
// 列表与层级
|
||||
// 列表与层级 - 短名称映射到完整模板名
|
||||
'list-grid': 'list-grid-compact-card',
|
||||
'list-column': 'list-column-simple-vertical-arrow',
|
||||
'list-row': 'list-row-simple-horizontal-arrow',
|
||||
'hierarchy-tree': 'hierarchy-tree-tech-style-capsule-item',
|
||||
|
||||
// 时序与时间线
|
||||
'sequence-roadmap-vertical': 'sequence-roadmap-vertical-simple',
|
||||
'sequence-timeline': 'sequence-timeline-simple',
|
||||
'sequence-steps': 'sequence-steps-simple',
|
||||
'sequence-horizontal-zigzag': 'sequence-horizontal-zigzag-simple',
|
||||
|
||||
// 对比
|
||||
'compare-binary-horizontal': 'compare-binary-horizontal-simple-vs',
|
||||
'compare-hierarchy-row': 'compare-hierarchy-row-simple',
|
||||
|
||||
// 图表
|
||||
'chart-column': 'chart-column-simple',
|
||||
'quadrant': 'quadrant-quarter-simple-card',
|
||||
|
||||
// 向后兼容的旧映射
|
||||
'list-vertical': 'list-column-simple-vertical-arrow',
|
||||
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
|
||||
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
|
||||
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
|
||||
|
||||
// 顺序与关系
|
||||
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
|
||||
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
|
||||
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
|
||||
'relation-sankey': 'relation-sankey-simple', // 暂无直接对应,保留原值或需移除
|
||||
'relation-circle': 'relation-circle-icon-badge',
|
||||
|
||||
// 对比与分析
|
||||
'compare-binary': 'compare-binary-horizontal-simple-vs',
|
||||
'compare-swot': 'compare-swot',
|
||||
'quadrant-quarter': 'quadrant-quarter-simple-card',
|
||||
|
||||
// 图表与数据
|
||||
'statistic-card': 'list-grid-compact-card',
|
||||
'chart-bar': 'chart-bar-plain-text',
|
||||
'chart-column': 'chart-column-simple',
|
||||
'chart-line': 'chart-line-plain-text',
|
||||
'chart-area': 'chart-area-simple', // 暂无直接对应
|
||||
'chart-pie': 'chart-pie-plain-text',
|
||||
'chart-doughnut': 'chart-pie-donut-plain-text'
|
||||
};
|
||||
@@ -657,10 +719,48 @@ SCRIPT_TEMPLATE_INFOGRAPHIC = """
|
||||
containerEl.dataset.infographicRendered = 'true';
|
||||
console.log('[Infographic] 渲染完成');
|
||||
|
||||
// 自动调整高度
|
||||
// 自动调整高度与元素标记
|
||||
setTimeout(() => {
|
||||
const svg = containerEl.querySelector('svg');
|
||||
if (svg) {
|
||||
// 1. 标记元素以便 CSS 应用样式
|
||||
const fos = Array.from(svg.querySelectorAll('foreignObject'));
|
||||
let titleFound = false;
|
||||
let descFound = false;
|
||||
|
||||
fos.forEach((fo) => {
|
||||
const text = fo.textContent.trim();
|
||||
if (!text || fo.querySelector('i') || (fo.querySelector('svg') && fo.querySelectorAll('*').length < 5)) {
|
||||
fo.setAttribute('data-element-type', 'icon');
|
||||
return;
|
||||
}
|
||||
|
||||
// 动态增加高度和宽度,容纳换行后的文字
|
||||
const currentHeight = parseInt(fo.getAttribute('height') || '0');
|
||||
if (currentHeight > 0 && currentHeight < 200) {
|
||||
fo.setAttribute('height', Math.round(currentHeight * 1.8).toString());
|
||||
}
|
||||
const currentWidth = parseInt(fo.getAttribute('width') || '0');
|
||||
if (currentWidth > 0 && currentWidth < 300) {
|
||||
fo.setAttribute('width', Math.max(Math.round(currentWidth * 1.2), 180).toString());
|
||||
}
|
||||
|
||||
if (!titleFound) {
|
||||
fo.setAttribute('data-element-type', 'title');
|
||||
titleFound = true;
|
||||
} else if (!descFound) {
|
||||
fo.setAttribute('data-element-type', 'desc');
|
||||
descFound = true;
|
||||
} else {
|
||||
if (fo.querySelector('strong') || fo.style.fontWeight === 'bold' || text.length < 15) {
|
||||
fo.setAttribute('data-element-type', 'item-label');
|
||||
} else {
|
||||
fo.setAttribute('data-element-type', 'item-desc');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 调整高度
|
||||
const bbox = svg.getBoundingClientRect();
|
||||
let contentHeight = bbox.height;
|
||||
if (svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.height) {
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# Infographic to Markdown
|
||||
|
||||
> **Version:** 1.0.0
|
||||
|
||||
AI-powered infographic generator that renders SVG on the frontend and embeds it directly into Markdown as a Data URL image.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin combines the power of AI text analysis with AntV Infographic visualization to create beautiful infographics that are embedded directly into chat messages as Markdown images.
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Open WebUI Plugin │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. Python Action │
|
||||
│ ├── Receive message content │
|
||||
│ ├── Call LLM to generate Infographic syntax │
|
||||
│ └── Send __event_call__ to execute frontend JS │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2. Browser JS (via __event_call__) │
|
||||
│ ├── Dynamically load AntV Infographic library │
|
||||
│ ├── Render SVG offscreen │
|
||||
│ ├── Export to Data URL via toDataURL() │
|
||||
│ └── Update message content via REST API │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 3. Markdown Rendering │
|
||||
│ └── Display  │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **AI-Powered**: Automatically analyzes text and selects the best infographic template
|
||||
- 📊 **Multiple Templates**: Supports 18+ infographic templates (lists, charts, comparisons, etc.)
|
||||
- 🖼️ **Self-Contained**: SVG/PNG embedded as Data URL, no external dependencies
|
||||
- 📝 **Markdown Native**: Results are pure Markdown images, compatible everywhere
|
||||
- 🔄 **API Writeback**: Updates message content via REST API for persistence
|
||||
|
||||
## Plugins in This Directory
|
||||
|
||||
### 1. `infographic_markdown.py` - Main Plugin ⭐
|
||||
- **Purpose**: Production use
|
||||
- **Features**: Full AI + AntV Infographic + Data URL embedding
|
||||
|
||||
### 2. `js_render_poc.py` - Proof of Concept
|
||||
- **Purpose**: Learning and testing
|
||||
- **Features**: Simple SVG creation demo, `__event_call__` pattern
|
||||
|
||||
## Configuration (Valves)
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `SHOW_STATUS` | bool | `true` | Show operation status updates |
|
||||
| `MODEL_ID` | string | `""` | LLM model ID (empty = use current model) |
|
||||
| `MIN_TEXT_LENGTH` | int | `50` | Minimum text length required |
|
||||
| `MESSAGE_COUNT` | int | `1` | Number of recent messages to use |
|
||||
| `SVG_WIDTH` | int | `800` | Width of generated SVG (pixels) |
|
||||
| `EXPORT_FORMAT` | string | `"svg"` | Export format: `svg` or `png` |
|
||||
|
||||
## Supported Templates
|
||||
|
||||
| Category | Template | Description |
|
||||
|----------|----------|-------------|
|
||||
| List | `list-grid` | Grid cards |
|
||||
| List | `list-vertical` | Vertical list |
|
||||
| Tree | `tree-vertical` | Vertical tree |
|
||||
| Tree | `tree-horizontal` | Horizontal tree |
|
||||
| Mind Map | `mindmap` | Mind map |
|
||||
| Process | `sequence-roadmap` | Roadmap |
|
||||
| Process | `sequence-zigzag` | Zigzag process |
|
||||
| Relation | `relation-sankey` | Sankey diagram |
|
||||
| Relation | `relation-circle` | Circular relation |
|
||||
| Compare | `compare-binary` | Binary comparison |
|
||||
| Analysis | `compare-swot` | SWOT analysis |
|
||||
| Quadrant | `quadrant-quarter` | Quadrant chart |
|
||||
| Chart | `chart-bar` | Bar chart |
|
||||
| Chart | `chart-column` | Column chart |
|
||||
| Chart | `chart-line` | Line chart |
|
||||
| Chart | `chart-pie` | Pie chart |
|
||||
| Chart | `chart-doughnut` | Doughnut chart |
|
||||
| Chart | `chart-area` | Area chart |
|
||||
|
||||
## Syntax Examples
|
||||
|
||||
### Grid List
|
||||
```infographic
|
||||
infographic list-grid
|
||||
data
|
||||
title Project Overview
|
||||
items
|
||||
- label Module A
|
||||
desc Description of module A
|
||||
- label Module B
|
||||
desc Description of module B
|
||||
```
|
||||
|
||||
### Binary Comparison
|
||||
```infographic
|
||||
infographic compare-binary
|
||||
data
|
||||
title Pros vs Cons
|
||||
items
|
||||
- label Pros
|
||||
children
|
||||
- label Strong R&D
|
||||
desc Technology leadership
|
||||
- label Cons
|
||||
children
|
||||
- label Weak brand
|
||||
desc Insufficient marketing
|
||||
```
|
||||
|
||||
### Bar Chart
|
||||
```infographic
|
||||
infographic chart-bar
|
||||
data
|
||||
title Quarterly Revenue
|
||||
items
|
||||
- label Q1
|
||||
value 120
|
||||
- label Q2
|
||||
value 150
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Data URL Embedding
|
||||
```javascript
|
||||
// SVG to Base64 Data URL
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = "data:image/svg+xml;base64," + base64;
|
||||
|
||||
// Markdown image syntax
|
||||
const markdownImage = ``;
|
||||
```
|
||||
|
||||
### AntV toDataURL API
|
||||
```javascript
|
||||
// Export as SVG (recommended, supports embedded resources)
|
||||
const svgUrl = await instance.toDataURL({
|
||||
type: 'svg',
|
||||
embedResources: true
|
||||
});
|
||||
|
||||
// Export as PNG (more compatible but larger)
|
||||
const pngUrl = await instance.toDataURL({
|
||||
type: 'png',
|
||||
dpr: 2
|
||||
});
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Browser Compatibility**: Requires modern browsers with ES6+ and Fetch API support
|
||||
2. **Network Dependency**: First use requires loading AntV library from CDN
|
||||
3. **Data URL Size**: Base64 encoding increases size by ~33%
|
||||
4. **Chinese Fonts**: SVG export embeds fonts for correct display
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [AntV Infographic Documentation](https://infographic.antv.vision/)
|
||||
- [Infographic API Reference](https://infographic.antv.vision/reference/infographic-api)
|
||||
- [Infographic Syntax Guide](https://infographic.antv.vision/learn/infographic-syntax)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
@@ -1,174 +0,0 @@
|
||||
# 信息图转 Markdown
|
||||
|
||||
> **版本:** 1.0.0
|
||||
|
||||
AI 驱动的信息图生成器,在前端渲染 SVG 并以 Data URL 图片格式直接嵌入到 Markdown 中。
|
||||
|
||||
## 概述
|
||||
|
||||
这个插件结合了 AI 文本分析能力和 AntV Infographic 可视化引擎,生成精美的信息图并以 Markdown 图片格式直接嵌入到聊天消息中。
|
||||
|
||||
### 工作原理
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Open WebUI 插件 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. Python Action │
|
||||
│ ├── 接收消息内容 │
|
||||
│ ├── 调用 LLM 生成 Infographic 语法 │
|
||||
│ └── 发送 __event_call__ 执行前端 JS │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2. 浏览器 JS (通过 __event_call__) │
|
||||
│ ├── 动态加载 AntV Infographic 库 │
|
||||
│ ├── 离屏渲染 SVG │
|
||||
│ ├── 使用 toDataURL() 导出 Data URL │
|
||||
│ └── 通过 REST API 更新消息内容 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 3. Markdown 渲染 │
|
||||
│ └── 显示  │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 功能特点
|
||||
|
||||
- 🤖 **AI 驱动**: 自动分析文本并选择最佳的信息图模板
|
||||
- 📊 **多种模板**: 支持 18+ 种信息图模板(列表、图表、对比等)
|
||||
- 🖼️ **自包含**: SVG/PNG 以 Data URL 嵌入,无外部依赖
|
||||
- 📝 **Markdown 原生**: 结果是纯 Markdown 图片,兼容任何平台
|
||||
- 🔄 **API 回写**: 通过 REST API 更新消息内容实现持久化
|
||||
|
||||
## 目录中的插件
|
||||
|
||||
### 1. `infographic_markdown.py` - 主插件 ⭐
|
||||
- **用途**: 生产使用
|
||||
- **功能**: 完整的 AI + AntV Infographic + Data URL 嵌入
|
||||
|
||||
### 2. `infographic_markdown_cn.py` - 主插件(中文版)
|
||||
- **用途**: 生产使用
|
||||
- **功能**: 与英文版相同,界面文字为中文
|
||||
|
||||
### 3. `js_render_poc.py` - 概念验证
|
||||
- **用途**: 学习和测试
|
||||
- **功能**: 简单的 SVG 创建演示,`__event_call__` 模式
|
||||
|
||||
## 配置选项 (Valves)
|
||||
|
||||
| 参数 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `SHOW_STATUS` | bool | `true` | 是否显示操作状态 |
|
||||
| `MODEL_ID` | string | `""` | LLM 模型 ID(空则使用当前模型) |
|
||||
| `MIN_TEXT_LENGTH` | int | `50` | 最小文本长度要求 |
|
||||
| `MESSAGE_COUNT` | int | `1` | 用于生成的最近消息数量 |
|
||||
| `SVG_WIDTH` | int | `800` | 生成的 SVG 宽度(像素) |
|
||||
| `EXPORT_FORMAT` | string | `"svg"` | 导出格式:`svg` 或 `png` |
|
||||
|
||||
## 支持的模板
|
||||
|
||||
| 类别 | 模板名称 | 描述 |
|
||||
|------|----------|------|
|
||||
| 列表 | `list-grid` | 网格卡片 |
|
||||
| 列表 | `list-vertical` | 垂直列表 |
|
||||
| 树形 | `tree-vertical` | 垂直树 |
|
||||
| 树形 | `tree-horizontal` | 水平树 |
|
||||
| 思维导图 | `mindmap` | 思维导图 |
|
||||
| 流程 | `sequence-roadmap` | 路线图 |
|
||||
| 流程 | `sequence-zigzag` | 折线流程 |
|
||||
| 关系 | `relation-sankey` | 桑基图 |
|
||||
| 关系 | `relation-circle` | 圆形关系 |
|
||||
| 对比 | `compare-binary` | 二元对比 |
|
||||
| 分析 | `compare-swot` | SWOT 分析 |
|
||||
| 象限 | `quadrant-quarter` | 四象限图 |
|
||||
| 图表 | `chart-bar` | 条形图 |
|
||||
| 图表 | `chart-column` | 柱状图 |
|
||||
| 图表 | `chart-line` | 折线图 |
|
||||
| 图表 | `chart-pie` | 饼图 |
|
||||
| 图表 | `chart-doughnut` | 环形图 |
|
||||
| 图表 | `chart-area` | 面积图 |
|
||||
|
||||
## 语法示例
|
||||
|
||||
### 网格列表
|
||||
```infographic
|
||||
infographic list-grid
|
||||
data
|
||||
title 项目概览
|
||||
items
|
||||
- label 模块一
|
||||
desc 这是第一个模块的描述
|
||||
- label 模块二
|
||||
desc 这是第二个模块的描述
|
||||
```
|
||||
|
||||
### 二元对比
|
||||
```infographic
|
||||
infographic compare-binary
|
||||
data
|
||||
title 优劣对比
|
||||
items
|
||||
- label 优势
|
||||
children
|
||||
- label 研发能力强
|
||||
desc 技术领先
|
||||
- label 劣势
|
||||
children
|
||||
- label 品牌曝光不足
|
||||
desc 营销力度不够
|
||||
```
|
||||
|
||||
### 条形图
|
||||
```infographic
|
||||
infographic chart-bar
|
||||
data
|
||||
title 季度收入
|
||||
items
|
||||
- label Q1
|
||||
value 120
|
||||
- label Q2
|
||||
value 150
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
### Data URL 嵌入
|
||||
```javascript
|
||||
// SVG 转 Base64 Data URL
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = "data:image/svg+xml;base64," + base64;
|
||||
|
||||
// Markdown 图片语法
|
||||
const markdownImage = ``;
|
||||
```
|
||||
|
||||
### AntV toDataURL API
|
||||
```javascript
|
||||
// 导出 SVG(推荐,支持嵌入资源)
|
||||
const svgUrl = await instance.toDataURL({
|
||||
type: 'svg',
|
||||
embedResources: true
|
||||
});
|
||||
|
||||
// 导出 PNG(更兼容但体积更大)
|
||||
const pngUrl = await instance.toDataURL({
|
||||
type: 'png',
|
||||
dpr: 2
|
||||
});
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **浏览器兼容性**: 需要现代浏览器支持 ES6+ 和 Fetch API
|
||||
2. **网络依赖**: 首次使用需要从 CDN 加载 AntV Infographic 库
|
||||
3. **Data URL 大小**: Base64 编码会增加约 33% 的体积
|
||||
4. **中文字体**: SVG 导出时会嵌入字体以确保正确显示
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [AntV Infographic 官方文档](https://infographic.antv.vision/)
|
||||
- [Infographic API 参考](https://infographic.antv.vision/reference/infographic-api)
|
||||
- [Infographic 语法规范](https://infographic.antv.vision/learn/infographic-syntax)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -1,592 +0,0 @@
|
||||
"""
|
||||
title: 📊 Infographic to Markdown
|
||||
author: Fu-Jie
|
||||
version: 1.0.0
|
||||
description: AI生成信息图语法,前端渲染SVG并转换为Markdown图片格式嵌入消息。支持AntV Infographic模板。
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Callable, Awaitable, Any, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# LLM Prompts
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_INFOGRAPHIC = """
|
||||
You are a professional infographic design expert who can analyze user-provided text content and convert it into AntV Infographic syntax format.
|
||||
|
||||
## Infographic Syntax Specification
|
||||
|
||||
Infographic syntax is a Mermaid-like declarative syntax for describing infographic templates, data, and themes.
|
||||
|
||||
### Syntax Rules
|
||||
- Entry uses `infographic <template-name>`
|
||||
- Key-value pairs are separated by spaces, **absolutely NO colons allowed**
|
||||
- Use two spaces for indentation
|
||||
- Object arrays use `-` with line breaks
|
||||
|
||||
⚠️ **IMPORTANT WARNING: This is NOT YAML format!**
|
||||
- ❌ Wrong: `children:` `items:` `data:` (with colons)
|
||||
- ✅ Correct: `children` `items` `data` (without colons)
|
||||
|
||||
### Template Library & Selection Guide
|
||||
|
||||
Choose the most appropriate template based on the content structure:
|
||||
|
||||
#### 1. List & Hierarchy
|
||||
- **List**: `list-grid` (Grid Cards), `list-vertical` (Vertical List)
|
||||
- **Tree**: `tree-vertical` (Vertical Tree), `tree-horizontal` (Horizontal Tree)
|
||||
- **Mindmap**: `mindmap` (Mind Map)
|
||||
|
||||
#### 2. Sequence & Relationship
|
||||
- **Process**: `sequence-roadmap` (Roadmap), `sequence-zigzag` (Zigzag Process)
|
||||
- **Relationship**: `relation-sankey` (Sankey Diagram), `relation-circle` (Circular)
|
||||
|
||||
#### 3. Comparison & Analysis
|
||||
- **Comparison**: `compare-binary` (Binary Comparison)
|
||||
- **Analysis**: `compare-swot` (SWOT Analysis), `quadrant-quarter` (Quadrant Chart)
|
||||
|
||||
#### 4. Charts & Data
|
||||
- **Charts**: `chart-bar`, `chart-column`, `chart-line`, `chart-pie`, `chart-doughnut`, `chart-area`
|
||||
|
||||
### Data Structure Examples
|
||||
|
||||
#### A. Standard List/Tree
|
||||
```infographic
|
||||
infographic list-grid
|
||||
data
|
||||
title Project Modules
|
||||
items
|
||||
- label Module A
|
||||
desc Description of A
|
||||
- label Module B
|
||||
desc Description of B
|
||||
```
|
||||
|
||||
#### B. Binary Comparison
|
||||
```infographic
|
||||
infographic compare-binary
|
||||
data
|
||||
title Advantages vs Disadvantages
|
||||
items
|
||||
- label Advantages
|
||||
children
|
||||
- label Strong R&D
|
||||
desc Leading technology
|
||||
- label Disadvantages
|
||||
children
|
||||
- label Weak brand
|
||||
desc Insufficient marketing
|
||||
```
|
||||
|
||||
#### C. Charts
|
||||
```infographic
|
||||
infographic chart-bar
|
||||
data
|
||||
title Quarterly Revenue
|
||||
items
|
||||
- label Q1
|
||||
value 120
|
||||
- label Q2
|
||||
value 150
|
||||
```
|
||||
|
||||
### Common Data Fields
|
||||
- `label`: Main title/label (Required)
|
||||
- `desc`: Description text (max 30 Chinese chars / 60 English chars for `list-grid`)
|
||||
- `value`: Numeric value (for charts)
|
||||
- `children`: Nested items
|
||||
|
||||
## Output Requirements
|
||||
1. **Language**: Output content in the user's language.
|
||||
2. **Format**: Wrap output in ```infographic ... ```.
|
||||
3. **No Colons**: Do NOT use colons after keys.
|
||||
4. **Indentation**: Use 2 spaces.
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE = """
|
||||
Please analyze the following text content and convert its core information into AntV Infographic syntax format.
|
||||
|
||||
---
|
||||
**User Context:**
|
||||
User Name: {user_name}
|
||||
Current Date/Time: {current_date_time_str}
|
||||
User Language: {user_language}
|
||||
---
|
||||
|
||||
**Text Content:**
|
||||
{long_text_content}
|
||||
|
||||
Please select the most appropriate infographic template based on text characteristics and output standard infographic syntax.
|
||||
|
||||
**Important Note:**
|
||||
- If using `list-grid` format, ensure each card's `desc` description is limited to **maximum 30 Chinese characters** (or **approximately 60 English characters**).
|
||||
- Descriptions should be concise and highlight key points.
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True, description="Show operation status updates in chat interface."
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="LLM model ID for text analysis. If empty, uses current conversation model.",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=50,
|
||||
description="Minimum text length (characters) required for infographic analysis.",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="Number of recent messages to use for generation.",
|
||||
)
|
||||
SVG_WIDTH: int = Field(
|
||||
default=800,
|
||||
description="Width of generated SVG in pixels.",
|
||||
)
|
||||
EXPORT_FORMAT: str = Field(
|
||||
default="svg",
|
||||
description="Export format: 'svg' or 'png'.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""Extract chat_id from body or metadata"""
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
chat_id = body_metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""Extract message_id from body or metadata"""
|
||||
if isinstance(body, dict):
|
||||
message_id = body.get("id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
message_id = body_metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_infographic_syntax(self, llm_output: str) -> str:
|
||||
"""Extract infographic syntax from LLM output"""
|
||||
match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
else:
|
||||
logger.warning("LLM output did not follow expected format, treating entire output as syntax.")
|
||||
return llm_output.strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""Extract text from message content, supporting multimodal formats"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
async def _emit_status(self, emitter, description: str, done: bool = False):
|
||||
"""Send status update event"""
|
||||
if self.valves.SHOW_STATUS and emitter:
|
||||
await emitter(
|
||||
{"type": "status", "data": {"description": description, "done": done}}
|
||||
)
|
||||
|
||||
def _generate_js_code(
|
||||
self,
|
||||
unique_id: str,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
infographic_syntax: str,
|
||||
svg_width: int,
|
||||
export_format: str,
|
||||
) -> str:
|
||||
"""Generate JavaScript code for frontend SVG rendering"""
|
||||
|
||||
# Escape the syntax for JS embedding
|
||||
syntax_escaped = (
|
||||
infographic_syntax
|
||||
.replace("\\", "\\\\")
|
||||
.replace("`", "\\`")
|
||||
.replace("${", "\\${")
|
||||
.replace("</script>", "<\\/script>")
|
||||
)
|
||||
|
||||
# Template mapping (same as infographic.py)
|
||||
template_mapping_js = """
|
||||
const TEMPLATE_MAPPING = {
|
||||
'list-grid': 'list-grid-compact-card',
|
||||
'list-vertical': 'list-column-simple-vertical-arrow',
|
||||
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
|
||||
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
|
||||
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
|
||||
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
|
||||
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
|
||||
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
|
||||
'relation-sankey': 'relation-sankey-simple',
|
||||
'relation-circle': 'relation-circle-icon-badge',
|
||||
'compare-binary': 'compare-binary-horizontal-simple-vs',
|
||||
'compare-swot': 'compare-swot',
|
||||
'quadrant-quarter': 'quadrant-quarter-simple-card',
|
||||
'statistic-card': 'list-grid-compact-card',
|
||||
'chart-bar': 'chart-bar-plain-text',
|
||||
'chart-column': 'chart-column-simple',
|
||||
'chart-line': 'chart-line-plain-text',
|
||||
'chart-area': 'chart-area-simple',
|
||||
'chart-pie': 'chart-pie-plain-text',
|
||||
'chart-doughnut': 'chart-pie-donut-plain-text'
|
||||
};
|
||||
"""
|
||||
|
||||
return f"""
|
||||
(async function() {{
|
||||
const uniqueId = "{unique_id}";
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
const svgWidth = {svg_width};
|
||||
const exportFormat = "{export_format}";
|
||||
|
||||
console.log("[Infographic Markdown] Starting render...");
|
||||
console.log("[Infographic Markdown] chatId:", chatId, "messageId:", messageId);
|
||||
|
||||
try {{
|
||||
// Load AntV Infographic if not loaded
|
||||
if (typeof AntVInfographic === 'undefined') {{
|
||||
console.log("[Infographic Markdown] Loading AntV Infographic library...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
console.log("[Infographic Markdown] Library loaded.");
|
||||
}}
|
||||
|
||||
const {{ Infographic }} = AntVInfographic;
|
||||
|
||||
// Get infographic syntax
|
||||
let syntaxContent = `{syntax_escaped}`;
|
||||
console.log("[Infographic Markdown] Original syntax:", syntaxContent.substring(0, 200) + "...");
|
||||
|
||||
// Clean up syntax
|
||||
const backtick = String.fromCharCode(96);
|
||||
const prefix = backtick + backtick + backtick + 'infographic';
|
||||
const simplePrefix = backtick + backtick + backtick;
|
||||
|
||||
if (syntaxContent.toLowerCase().startsWith(prefix)) {{
|
||||
syntaxContent = syntaxContent.substring(prefix.length).trim();
|
||||
}} else if (syntaxContent.startsWith(simplePrefix)) {{
|
||||
syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
|
||||
}}
|
||||
|
||||
if (syntaxContent.endsWith(simplePrefix)) {{
|
||||
syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
|
||||
}}
|
||||
|
||||
// Fix colons after keywords
|
||||
syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
|
||||
syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
|
||||
|
||||
// Ensure infographic prefix
|
||||
if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
|
||||
syntaxContent = 'infographic list-grid\\n' + syntaxContent;
|
||||
}}
|
||||
|
||||
// Apply template mapping
|
||||
{template_mapping_js}
|
||||
|
||||
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
|
||||
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
|
||||
if (regex.test(syntaxContent)) {{
|
||||
console.log(`[Infographic Markdown] Auto-mapping: ${{key}} -> ${{value}}`);
|
||||
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
|
||||
console.log("[Infographic Markdown] Cleaned syntax:", syntaxContent.substring(0, 200) + "...");
|
||||
|
||||
// Create offscreen container
|
||||
const container = document.createElement('div');
|
||||
container.id = 'infographic-offscreen-' + uniqueId;
|
||||
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create and render infographic
|
||||
const instance = new Infographic({{
|
||||
container: '#' + container.id,
|
||||
width: svgWidth,
|
||||
padding: 24,
|
||||
}});
|
||||
|
||||
console.log("[Infographic Markdown] Rendering infographic...");
|
||||
instance.render(syntaxContent);
|
||||
|
||||
// Wait for render and export
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
let dataUrl;
|
||||
if (exportFormat === 'png') {{
|
||||
dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }});
|
||||
}} else {{
|
||||
dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }});
|
||||
}}
|
||||
|
||||
console.log("[Infographic Markdown] Data URL generated, length:", dataUrl.length);
|
||||
|
||||
// Cleanup
|
||||
instance.destroy();
|
||||
document.body.removeChild(container);
|
||||
|
||||
// Generate markdown image
|
||||
const markdownImage = ``;
|
||||
|
||||
// Update message via API
|
||||
if (chatId && messageId) {{
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// Get current message content
|
||||
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "GET",
|
||||
headers: {{ "Authorization": `Bearer ${{token}}` }}
|
||||
}});
|
||||
|
||||
if (!getResponse.ok) {{
|
||||
throw new Error("Failed to get chat data: " + getResponse.status);
|
||||
}}
|
||||
|
||||
const chatData = await getResponse.json();
|
||||
let originalContent = "";
|
||||
|
||||
if (chatData.chat && chatData.chat.messages) {{
|
||||
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
|
||||
if (targetMsg && targetMsg.content) {{
|
||||
originalContent = targetMsg.content;
|
||||
}}
|
||||
}}
|
||||
|
||||
// Remove existing infographic images
|
||||
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
|
||||
let cleanedContent = originalContent.replace(infographicPattern, "");
|
||||
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
||||
|
||||
// Append new image
|
||||
const newContent = cleanedContent + "\\n\\n" + markdownImage;
|
||||
|
||||
// Update message
|
||||
const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
type: "chat:message",
|
||||
data: {{ content: newContent }}
|
||||
}})
|
||||
}});
|
||||
|
||||
if (updateResponse.ok) {{
|
||||
console.log("[Infographic Markdown] ✅ Message updated successfully!");
|
||||
}} else {{
|
||||
console.error("[Infographic Markdown] API error:", updateResponse.status);
|
||||
}}
|
||||
}} else {{
|
||||
console.warn("[Infographic Markdown] ⚠️ Missing chatId or messageId");
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
console.error("[Infographic Markdown] Error:", error);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: dict = None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__request__: Request = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate infographic using AntV and embed as Markdown image.
|
||||
"""
|
||||
logger.info("Action: Infographic to Markdown started")
|
||||
|
||||
# Get user information
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = __user__[0].get("language", "en") if __user__ else "en"
|
||||
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
|
||||
user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user"
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "en")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
else:
|
||||
user_language = "en"
|
||||
user_name = "User"
|
||||
user_id = "unknown_user"
|
||||
|
||||
# Get current time
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("No messages available.")
|
||||
|
||||
# Get recent messages
|
||||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||||
recent_messages = messages[-message_count:]
|
||||
|
||||
# Aggregate content
|
||||
aggregated_parts = []
|
||||
for msg in recent_messages:
|
||||
text_content = self._extract_text_content(msg.get("content"))
|
||||
if text_content:
|
||||
aggregated_parts.append(text_content)
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("No text content found in messages.")
|
||||
|
||||
long_text_content = "\n\n---\n\n".join(aggregated_parts)
|
||||
|
||||
# Remove existing HTML blocks
|
||||
parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL)
|
||||
clean_content = ""
|
||||
for part in reversed(parts):
|
||||
if part.strip():
|
||||
clean_content = part.strip()
|
||||
break
|
||||
|
||||
if not clean_content:
|
||||
clean_content = long_text_content.strip()
|
||||
|
||||
# Check minimum length
|
||||
if len(clean_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
f"⚠️ 内容太短 ({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符",
|
||||
True,
|
||||
)
|
||||
return body
|
||||
|
||||
await self._emit_status(__event_emitter__, "📊 正在分析内容...", False)
|
||||
|
||||
# Generate infographic syntax via LLM
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
user_language=user_language,
|
||||
long_text_content=clean_content,
|
||||
)
|
||||
|
||||
target_model = self.valves.MODEL_ID or body.get("model")
|
||||
|
||||
llm_payload = {
|
||||
"model": target_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"Unable to get user object: {user_id}")
|
||||
|
||||
await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False)
|
||||
|
||||
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
|
||||
|
||||
if not llm_response or "choices" not in llm_response or not llm_response["choices"]:
|
||||
raise ValueError("Invalid LLM response.")
|
||||
|
||||
assistant_content = llm_response["choices"][0]["message"]["content"]
|
||||
infographic_syntax = self._extract_infographic_syntax(assistant_content)
|
||||
|
||||
logger.info(f"Generated syntax: {infographic_syntax[:200]}...")
|
||||
|
||||
# Extract IDs for API callback
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
unique_id = f"ig_{int(time.time() * 1000)}"
|
||||
|
||||
await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False)
|
||||
|
||||
# Execute JS to render and embed
|
||||
if __event_call__:
|
||||
js_code = self._generate_js_code(
|
||||
unique_id=unique_id,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
infographic_syntax=infographic_syntax,
|
||||
svg_width=self.valves.SVG_WIDTH,
|
||||
export_format=self.valves.EXPORT_FORMAT,
|
||||
)
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
|
||||
await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True)
|
||||
logger.info("Infographic to Markdown completed")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Infographic generation failed: {str(e)}"
|
||||
logger.error(error_message, exc_info=True)
|
||||
await self._emit_status(__event_emitter__, f"❌ {error_message}", True)
|
||||
|
||||
return body
|
||||
@@ -1,592 +0,0 @@
|
||||
"""
|
||||
title: 📊 信息图转 Markdown
|
||||
author: Fu-Jie
|
||||
version: 1.0.0
|
||||
description: AI 生成信息图语法,前端渲染 SVG 并转换为 Markdown 图片格式嵌入消息。支持 AntV Infographic 模板。
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Callable, Awaitable, Any, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# LLM 提示词
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_INFOGRAPHIC = """
|
||||
你是一位专业的信息图设计专家,能够分析用户提供的文本内容并将其转换为 AntV Infographic 语法格式。
|
||||
|
||||
## 信息图语法规范
|
||||
|
||||
信息图语法是一种类似 Mermaid 的声明式语法,用于描述信息图模板、数据和主题。
|
||||
|
||||
### 语法规则
|
||||
- 入口使用 `infographic <模板名>`
|
||||
- 键值对用空格分隔,**绝对不允许使用冒号**
|
||||
- 使用两个空格缩进
|
||||
- 对象数组使用 `-` 加换行
|
||||
|
||||
⚠️ **重要警告:这不是 YAML 格式!**
|
||||
- ❌ 错误:`children:` `items:` `data:`(带冒号)
|
||||
- ✅ 正确:`children` `items` `data`(不带冒号)
|
||||
|
||||
### 模板库与选择指南
|
||||
|
||||
根据内容结构选择最合适的模板:
|
||||
|
||||
#### 1. 列表与层级
|
||||
- **列表**:`list-grid`(网格卡片)、`list-vertical`(垂直列表)
|
||||
- **树形**:`tree-vertical`(垂直树)、`tree-horizontal`(水平树)
|
||||
- **思维导图**:`mindmap`(思维导图)
|
||||
|
||||
#### 2. 序列与关系
|
||||
- **流程**:`sequence-roadmap`(路线图)、`sequence-zigzag`(折线流程)
|
||||
- **关系**:`relation-sankey`(桑基图)、`relation-circle`(圆形关系)
|
||||
|
||||
#### 3. 对比与分析
|
||||
- **对比**:`compare-binary`(二元对比)
|
||||
- **分析**:`compare-swot`(SWOT 分析)、`quadrant-quarter`(象限图)
|
||||
|
||||
#### 4. 图表与数据
|
||||
- **图表**:`chart-bar`、`chart-column`、`chart-line`、`chart-pie`、`chart-doughnut`、`chart-area`
|
||||
|
||||
### 数据结构示例
|
||||
|
||||
#### A. 标准列表/树形
|
||||
```infographic
|
||||
infographic list-grid
|
||||
data
|
||||
title 项目模块
|
||||
items
|
||||
- label 模块 A
|
||||
desc 模块 A 的描述
|
||||
- label 模块 B
|
||||
desc 模块 B 的描述
|
||||
```
|
||||
|
||||
#### B. 二元对比
|
||||
```infographic
|
||||
infographic compare-binary
|
||||
data
|
||||
title 优势与劣势
|
||||
items
|
||||
- label 优势
|
||||
children
|
||||
- label 研发能力强
|
||||
desc 技术领先
|
||||
- label 劣势
|
||||
children
|
||||
- label 品牌曝光弱
|
||||
desc 营销不足
|
||||
```
|
||||
|
||||
#### C. 图表
|
||||
```infographic
|
||||
infographic chart-bar
|
||||
data
|
||||
title 季度收入
|
||||
items
|
||||
- label Q1
|
||||
value 120
|
||||
- label Q2
|
||||
value 150
|
||||
```
|
||||
|
||||
### 常用数据字段
|
||||
- `label`:主标题/标签(必填)
|
||||
- `desc`:描述文字(`list-grid` 最多 30 个中文字符)
|
||||
- `value`:数值(用于图表)
|
||||
- `children`:嵌套项
|
||||
|
||||
## 输出要求
|
||||
1. **语言**:使用用户的语言输出内容。
|
||||
2. **格式**:用 ```infographic ... ``` 包裹输出。
|
||||
3. **无冒号**:键后面不要使用冒号。
|
||||
4. **缩进**:使用 2 个空格。
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE = """
|
||||
请分析以下文本内容,将其核心信息转换为 AntV Infographic 语法格式。
|
||||
|
||||
---
|
||||
**用户上下文:**
|
||||
用户名:{user_name}
|
||||
当前时间:{current_date_time_str}
|
||||
用户语言:{user_language}
|
||||
---
|
||||
|
||||
**文本内容:**
|
||||
{long_text_content}
|
||||
|
||||
请根据文本特征选择最合适的信息图模板,输出标准的信息图语法。
|
||||
|
||||
**重要提示:**
|
||||
- 如果使用 `list-grid` 格式,确保每个卡片的 `desc` 描述限制在 **最多 30 个中文字符**。
|
||||
- 描述应简洁,突出重点。
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True, description="在聊天界面显示操作状态更新。"
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="用于文本分析的 LLM 模型 ID。留空则使用当前对话模型。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=50,
|
||||
description="信息图分析所需的最小文本长度(字符数)。",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="用于生成的最近消息数量。",
|
||||
)
|
||||
SVG_WIDTH: int = Field(
|
||||
default=800,
|
||||
description="生成的 SVG 宽度(像素)。",
|
||||
)
|
||||
EXPORT_FORMAT: str = Field(
|
||||
default="svg",
|
||||
description="导出格式:'svg' 或 'png'。",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""从 body 或 metadata 中提取 chat_id"""
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
chat_id = body_metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""从 body 或 metadata 中提取 message_id"""
|
||||
if isinstance(body, dict):
|
||||
message_id = body.get("id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
message_id = body_metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_infographic_syntax(self, llm_output: str) -> str:
|
||||
"""从 LLM 输出中提取信息图语法"""
|
||||
match = re.search(r"```infographic\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
else:
|
||||
logger.warning("LLM 输出未遵循预期格式,将整个输出作为语法处理。")
|
||||
return llm_output.strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""从消息内容中提取文本,支持多模态格式"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
async def _emit_status(self, emitter, description: str, done: bool = False):
|
||||
"""发送状态更新事件"""
|
||||
if self.valves.SHOW_STATUS and emitter:
|
||||
await emitter(
|
||||
{"type": "status", "data": {"description": description, "done": done}}
|
||||
)
|
||||
|
||||
def _generate_js_code(
|
||||
self,
|
||||
unique_id: str,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
infographic_syntax: str,
|
||||
svg_width: int,
|
||||
export_format: str,
|
||||
) -> str:
|
||||
"""生成用于前端 SVG 渲染的 JavaScript 代码"""
|
||||
|
||||
# 转义语法以便嵌入 JS
|
||||
syntax_escaped = (
|
||||
infographic_syntax
|
||||
.replace("\\", "\\\\")
|
||||
.replace("`", "\\`")
|
||||
.replace("${", "\\${")
|
||||
.replace("</script>", "<\\/script>")
|
||||
)
|
||||
|
||||
# 模板映射
|
||||
template_mapping_js = """
|
||||
const TEMPLATE_MAPPING = {
|
||||
'list-grid': 'list-grid-compact-card',
|
||||
'list-vertical': 'list-column-simple-vertical-arrow',
|
||||
'tree-vertical': 'hierarchy-tree-tech-style-capsule-item',
|
||||
'tree-horizontal': 'hierarchy-tree-lr-tech-style-capsule-item',
|
||||
'mindmap': 'hierarchy-mindmap-branch-gradient-capsule-item',
|
||||
'sequence-roadmap': 'sequence-roadmap-vertical-simple',
|
||||
'sequence-zigzag': 'sequence-horizontal-zigzag-simple',
|
||||
'sequence-horizontal': 'sequence-horizontal-zigzag-simple',
|
||||
'relation-sankey': 'relation-sankey-simple',
|
||||
'relation-circle': 'relation-circle-icon-badge',
|
||||
'compare-binary': 'compare-binary-horizontal-simple-vs',
|
||||
'compare-swot': 'compare-swot',
|
||||
'quadrant-quarter': 'quadrant-quarter-simple-card',
|
||||
'statistic-card': 'list-grid-compact-card',
|
||||
'chart-bar': 'chart-bar-plain-text',
|
||||
'chart-column': 'chart-column-simple',
|
||||
'chart-line': 'chart-line-plain-text',
|
||||
'chart-area': 'chart-area-simple',
|
||||
'chart-pie': 'chart-pie-plain-text',
|
||||
'chart-doughnut': 'chart-pie-donut-plain-text'
|
||||
};
|
||||
"""
|
||||
|
||||
return f"""
|
||||
(async function() {{
|
||||
const uniqueId = "{unique_id}";
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
const svgWidth = {svg_width};
|
||||
const exportFormat = "{export_format}";
|
||||
|
||||
console.log("[信息图 Markdown] 开始渲染...");
|
||||
console.log("[信息图 Markdown] chatId:", chatId, "messageId:", messageId);
|
||||
|
||||
try {{
|
||||
// 加载 AntV Infographic(如果尚未加载)
|
||||
if (typeof AntVInfographic === 'undefined') {{
|
||||
console.log("[信息图 Markdown] 正在加载 AntV Infographic 库...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/@antv/infographic@latest/dist/infographic.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
console.log("[信息图 Markdown] 库加载完成。");
|
||||
}}
|
||||
|
||||
const {{ Infographic }} = AntVInfographic;
|
||||
|
||||
// 获取信息图语法
|
||||
let syntaxContent = `{syntax_escaped}`;
|
||||
console.log("[信息图 Markdown] 原始语法:", syntaxContent.substring(0, 200) + "...");
|
||||
|
||||
// 清理语法
|
||||
const backtick = String.fromCharCode(96);
|
||||
const prefix = backtick + backtick + backtick + 'infographic';
|
||||
const simplePrefix = backtick + backtick + backtick;
|
||||
|
||||
if (syntaxContent.toLowerCase().startsWith(prefix)) {{
|
||||
syntaxContent = syntaxContent.substring(prefix.length).trim();
|
||||
}} else if (syntaxContent.startsWith(simplePrefix)) {{
|
||||
syntaxContent = syntaxContent.substring(simplePrefix.length).trim();
|
||||
}}
|
||||
|
||||
if (syntaxContent.endsWith(simplePrefix)) {{
|
||||
syntaxContent = syntaxContent.substring(0, syntaxContent.length - simplePrefix.length).trim();
|
||||
}}
|
||||
|
||||
// 修复关键字后的冒号
|
||||
syntaxContent = syntaxContent.replace(/^(data|items|children|theme|config):/gm, '$1');
|
||||
syntaxContent = syntaxContent.replace(/(\\s)(children|items):/g, '$1$2');
|
||||
|
||||
// 确保有 infographic 前缀
|
||||
if (!syntaxContent.trim().toLowerCase().startsWith('infographic')) {{
|
||||
syntaxContent = 'infographic list-grid\\n' + syntaxContent;
|
||||
}}
|
||||
|
||||
// 应用模板映射
|
||||
{template_mapping_js}
|
||||
|
||||
for (const [key, value] of Object.entries(TEMPLATE_MAPPING)) {{
|
||||
const regex = new RegExp(`infographic\\\\s+${{key}}(?=\\\\s|$)`, 'i');
|
||||
if (regex.test(syntaxContent)) {{
|
||||
console.log(`[信息图 Markdown] 自动映射: ${{key}} -> ${{value}}`);
|
||||
syntaxContent = syntaxContent.replace(regex, `infographic ${{value}}`);
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
|
||||
console.log("[信息图 Markdown] 清理后语法:", syntaxContent.substring(0, 200) + "...");
|
||||
|
||||
// 创建离屏容器
|
||||
const container = document.createElement('div');
|
||||
container.id = 'infographic-offscreen-' + uniqueId;
|
||||
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 创建并渲染信息图
|
||||
const instance = new Infographic({{
|
||||
container: '#' + container.id,
|
||||
width: svgWidth,
|
||||
padding: 24,
|
||||
}});
|
||||
|
||||
console.log("[信息图 Markdown] 正在渲染信息图...");
|
||||
instance.render(syntaxContent);
|
||||
|
||||
// 等待渲染完成并导出
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
let dataUrl;
|
||||
if (exportFormat === 'png') {{
|
||||
dataUrl = await instance.toDataURL({{ type: 'png', dpr: 2 }});
|
||||
}} else {{
|
||||
dataUrl = await instance.toDataURL({{ type: 'svg', embedResources: true }});
|
||||
}}
|
||||
|
||||
console.log("[信息图 Markdown] Data URL 已生成,长度:", dataUrl.length);
|
||||
|
||||
// 清理
|
||||
instance.destroy();
|
||||
document.body.removeChild(container);
|
||||
|
||||
// 生成 Markdown 图片
|
||||
const markdownImage = ``;
|
||||
|
||||
// 通过 API 更新消息
|
||||
if (chatId && messageId) {{
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// 获取当前消息内容
|
||||
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "GET",
|
||||
headers: {{ "Authorization": `Bearer ${{token}}` }}
|
||||
}});
|
||||
|
||||
if (!getResponse.ok) {{
|
||||
throw new Error("获取对话数据失败: " + getResponse.status);
|
||||
}}
|
||||
|
||||
const chatData = await getResponse.json();
|
||||
let originalContent = "";
|
||||
|
||||
if (chatData.chat && chatData.chat.messages) {{
|
||||
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
|
||||
if (targetMsg && targetMsg.content) {{
|
||||
originalContent = targetMsg.content;
|
||||
}}
|
||||
}}
|
||||
|
||||
// 移除已有的信息图图片
|
||||
const infographicPattern = /\\n*!\\[📊[^\\]]*\\]\\(data:image\\/[^)]+\\)/g;
|
||||
let cleanedContent = originalContent.replace(infographicPattern, "");
|
||||
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
||||
|
||||
// 追加新图片
|
||||
const newContent = cleanedContent + "\\n\\n" + markdownImage;
|
||||
|
||||
// 更新消息
|
||||
const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
type: "chat:message",
|
||||
data: {{ content: newContent }}
|
||||
}})
|
||||
}});
|
||||
|
||||
if (updateResponse.ok) {{
|
||||
console.log("[信息图 Markdown] ✅ 消息更新成功!");
|
||||
}} else {{
|
||||
console.error("[信息图 Markdown] API 错误:", updateResponse.status);
|
||||
}}
|
||||
}} else {{
|
||||
console.warn("[信息图 Markdown] ⚠️ 缺少 chatId 或 messageId");
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
console.error("[信息图 Markdown] 错误:", error);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: dict = None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__request__: Request = None,
|
||||
) -> dict:
|
||||
"""
|
||||
使用 AntV 生成信息图并作为 Markdown 图片嵌入。
|
||||
"""
|
||||
logger.info("动作:信息图转 Markdown 开始")
|
||||
|
||||
# 获取用户信息
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = __user__[0].get("language", "zh") if __user__ else "zh"
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = __user__[0].get("id", "unknown_user") if __user__ else "unknown_user"
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
else:
|
||||
user_language = "zh"
|
||||
user_name = "用户"
|
||||
user_id = "unknown_user"
|
||||
|
||||
# 获取当前时间
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("没有可用的消息。")
|
||||
|
||||
# 获取最近的消息
|
||||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||||
recent_messages = messages[-message_count:]
|
||||
|
||||
# 聚合内容
|
||||
aggregated_parts = []
|
||||
for msg in recent_messages:
|
||||
text_content = self._extract_text_content(msg.get("content"))
|
||||
if text_content:
|
||||
aggregated_parts.append(text_content)
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("消息中未找到文本内容。")
|
||||
|
||||
long_text_content = "\n\n---\n\n".join(aggregated_parts)
|
||||
|
||||
# 移除已有的 HTML 块
|
||||
parts = re.split(r"```html.*?```", long_text_content, flags=re.DOTALL)
|
||||
clean_content = ""
|
||||
for part in reversed(parts):
|
||||
if part.strip():
|
||||
clean_content = part.strip()
|
||||
break
|
||||
|
||||
if not clean_content:
|
||||
clean_content = long_text_content.strip()
|
||||
|
||||
# 检查最小长度
|
||||
if len(clean_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
f"⚠️ 内容太短({len(clean_content)} 字符),至少需要 {self.valves.MIN_TEXT_LENGTH} 字符",
|
||||
True,
|
||||
)
|
||||
return body
|
||||
|
||||
await self._emit_status(__event_emitter__, "📊 正在分析内容...", False)
|
||||
|
||||
# 通过 LLM 生成信息图语法
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
user_language=user_language,
|
||||
long_text_content=clean_content,
|
||||
)
|
||||
|
||||
target_model = self.valves.MODEL_ID or body.get("model")
|
||||
|
||||
llm_payload = {
|
||||
"model": target_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_INFOGRAPHIC},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象:{user_id}")
|
||||
|
||||
await self._emit_status(__event_emitter__, "📊 AI 正在生成信息图语法...", False)
|
||||
|
||||
llm_response = await generate_chat_completion(__request__, llm_payload, user_obj)
|
||||
|
||||
if not llm_response or "choices" not in llm_response or not llm_response["choices"]:
|
||||
raise ValueError("无效的 LLM 响应。")
|
||||
|
||||
assistant_content = llm_response["choices"][0]["message"]["content"]
|
||||
infographic_syntax = self._extract_infographic_syntax(assistant_content)
|
||||
|
||||
logger.info(f"生成的语法:{infographic_syntax[:200]}...")
|
||||
|
||||
# 提取 API 回调所需的 ID
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
unique_id = f"ig_{int(time.time() * 1000)}"
|
||||
|
||||
await self._emit_status(__event_emitter__, "📊 正在渲染 SVG...", False)
|
||||
|
||||
# 执行 JS 进行渲染和嵌入
|
||||
if __event_call__:
|
||||
js_code = self._generate_js_code(
|
||||
unique_id=unique_id,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
infographic_syntax=infographic_syntax,
|
||||
svg_width=self.valves.SVG_WIDTH,
|
||||
export_format=self.valves.EXPORT_FORMAT,
|
||||
)
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
|
||||
await self._emit_status(__event_emitter__, "✅ 信息图生成完成!", True)
|
||||
logger.info("信息图转 Markdown 完成")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"信息图生成失败:{str(e)}"
|
||||
logger.error(error_message, exc_info=True)
|
||||
await self._emit_status(__event_emitter__, f"❌ {error_message}", True)
|
||||
|
||||
return body
|
||||
@@ -1,257 +0,0 @@
|
||||
"""
|
||||
title: JS Render PoC
|
||||
author: Fu-Jie
|
||||
version: 0.6.0
|
||||
description: Proof of concept for JS rendering + API write-back pattern. JS renders SVG and updates message via API.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Callable, Awaitable, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import Request
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""Extract chat_id from body or metadata"""
|
||||
if isinstance(body, dict):
|
||||
# body["chat_id"] 是 chat_id
|
||||
chat_id = body.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
chat_id = body_metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""Extract message_id from body or metadata"""
|
||||
if isinstance(body, dict):
|
||||
# body["id"] 是 message_id
|
||||
message_id = body.get("id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
message_id = body_metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: dict = None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__request__: Request = None,
|
||||
) -> dict:
|
||||
"""
|
||||
PoC: Use __event_call__ to execute JS that renders SVG and updates message via API.
|
||||
"""
|
||||
# 准备调试数据
|
||||
body_for_log = {}
|
||||
for k, v in body.items():
|
||||
if k == "messages":
|
||||
body_for_log[k] = f"[{len(v)} messages]"
|
||||
else:
|
||||
body_for_log[k] = v
|
||||
|
||||
body_json = json.dumps(body_for_log, ensure_ascii=False, default=str)
|
||||
metadata_json = (
|
||||
json.dumps(__metadata__, ensure_ascii=False, default=str)
|
||||
if __metadata__
|
||||
else "null"
|
||||
)
|
||||
|
||||
# 转义 JSON 中的特殊字符以便嵌入 JS
|
||||
body_json_escaped = (
|
||||
body_json.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
|
||||
)
|
||||
metadata_json_escaped = (
|
||||
metadata_json.replace("\\", "\\\\")
|
||||
.replace("`", "\\`")
|
||||
.replace("${", "\\${")
|
||||
)
|
||||
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
|
||||
unique_id = f"poc_{int(time.time() * 1000)}"
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "🔄 正在渲染...", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
if __event_call__:
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
(async function() {{
|
||||
const uniqueId = "{unique_id}";
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
|
||||
// ===== DEBUG: 输出 Python 端的数据 =====
|
||||
console.log("[JS Render PoC] ===== DEBUG INFO (from Python) =====");
|
||||
console.log("[JS Render PoC] body:", `{body_json_escaped}`);
|
||||
console.log("[JS Render PoC] __metadata__:", `{metadata_json_escaped}`);
|
||||
console.log("[JS Render PoC] Extracted: chatId=", chatId, "messageId=", messageId);
|
||||
console.log("[JS Render PoC] =========================================");
|
||||
|
||||
try {{
|
||||
console.log("[JS Render PoC] Starting SVG render...");
|
||||
|
||||
// Create SVG
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("width", "200");
|
||||
svg.setAttribute("height", "200");
|
||||
svg.setAttribute("viewBox", "0 0 200 200");
|
||||
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
|
||||
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
||||
const gradient = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
|
||||
gradient.setAttribute("id", "grad-" + uniqueId);
|
||||
gradient.innerHTML = `
|
||||
<stop offset="0%" style="stop-color:#1e88e5;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#43a047;stop-opacity:1" />
|
||||
`;
|
||||
defs.appendChild(gradient);
|
||||
svg.appendChild(defs);
|
||||
|
||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
circle.setAttribute("cx", "100");
|
||||
circle.setAttribute("cy", "100");
|
||||
circle.setAttribute("r", "80");
|
||||
circle.setAttribute("fill", `url(#grad-${{uniqueId}})`);
|
||||
svg.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
text.setAttribute("x", "100");
|
||||
text.setAttribute("y", "105");
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "16");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
text.textContent = "PoC Success!";
|
||||
svg.appendChild(text);
|
||||
|
||||
// Convert to Base64 Data URI
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = "data:image/svg+xml;base64," + base64;
|
||||
|
||||
console.log("[JS Render PoC] SVG rendered, data URI length:", dataUri.length);
|
||||
|
||||
// Call API - 完全替换方案(更稳定)
|
||||
if (chatId && messageId) {{
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// 1. 获取当前消息内容
|
||||
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "GET",
|
||||
headers: {{ "Authorization": `Bearer ${{token}}` }}
|
||||
}});
|
||||
|
||||
if (!getResponse.ok) {{
|
||||
throw new Error("Failed to get chat data: " + getResponse.status);
|
||||
}}
|
||||
|
||||
const chatData = await getResponse.json();
|
||||
console.log("[JS Render PoC] Got chat data");
|
||||
|
||||
let originalContent = "";
|
||||
if (chatData.chat && chatData.chat.messages) {{
|
||||
const targetMsg = chatData.chat.messages.find(m => m.id === messageId);
|
||||
if (targetMsg && targetMsg.content) {{
|
||||
originalContent = targetMsg.content;
|
||||
console.log("[JS Render PoC] Found original content, length:", originalContent.length);
|
||||
}}
|
||||
}}
|
||||
|
||||
// 2. 移除已存在的 PoC 图片(如果有的话)
|
||||
// 匹配  格式
|
||||
const pocImagePattern = /\\n*!\\[JS Render PoC[^\\]]*\\]\\(data:image\\/svg\\+xml;base64,[^)]+\\)/g;
|
||||
let cleanedContent = originalContent.replace(pocImagePattern, "");
|
||||
// 移除可能残留的多余空行
|
||||
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
||||
|
||||
if (cleanedContent !== originalContent) {{
|
||||
console.log("[JS Render PoC] Removed existing PoC image(s)");
|
||||
}}
|
||||
|
||||
// 3. 添加新的 Markdown 图片
|
||||
const markdownImage = ``;
|
||||
const newContent = cleanedContent + "\\n\\n" + markdownImage;
|
||||
|
||||
// 3. 使用 chat:message 完全替换
|
||||
const updateResponse = await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
type: "chat:message",
|
||||
data: {{ content: newContent }}
|
||||
}})
|
||||
}});
|
||||
|
||||
if (updateResponse.ok) {{
|
||||
console.log("[JS Render PoC] ✅ Message updated successfully!");
|
||||
}} else {{
|
||||
console.error("[JS Render PoC] API error:", updateResponse.status, await updateResponse.text());
|
||||
}}
|
||||
}} else {{
|
||||
console.warn("[JS Render PoC] ⚠️ Missing chatId or messageId, cannot persist.");
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
console.error("[JS Render PoC] Error:", error);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{"type": "status", "data": {"description": "✅ 渲染完成", "done": True}}
|
||||
)
|
||||
|
||||
return body
|
||||
BIN
plugins/actions/smart-mind-map/smart_mind_map.png
Normal file
|
After Width: | Height: | Size: 752 KiB |
BIN
plugins/actions/smart-mind-map/smart_mind_map_cn.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
@@ -1,30 +0,0 @@
|
||||
# Deep Reading & Summary
|
||||
|
||||
A powerful tool for analyzing long texts, generating detailed summaries, key points, and actionable insights.
|
||||
|
||||
## Features
|
||||
|
||||
- **Deep Analysis**: Goes beyond simple summarization to understand the core message.
|
||||
- **Key Point Extraction**: Identifies and lists the most important information.
|
||||
- **Actionable Advice**: Provides practical suggestions based on the text content.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin.
|
||||
2. Send a long text or article to the chat.
|
||||
3. Click the "Deep Reading" button (or trigger via command).
|
||||
|
||||
## Author
|
||||
|
||||
Fu-Jie
|
||||
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.1.2
|
||||
|
||||
- Removed debug messages from output
|
||||
@@ -1,30 +0,0 @@
|
||||
# 深度阅读与摘要 (Deep Reading & Summary)
|
||||
|
||||
一个强大的长文本分析工具,用于生成详细摘要、关键信息点和可执行的行动建议。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **深度分析**:超越简单的总结,深入理解核心信息。
|
||||
- **关键点提取**:识别并列出最重要的信息点。
|
||||
- **行动建议**:基于文本内容提供切实可行的建议。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 发送长文本或文章到聊天框。
|
||||
3. 点击“精读”按钮(或通过命令触发)。
|
||||
|
||||
## 作者
|
||||
|
||||
Fu-Jie
|
||||
GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.1.2
|
||||
|
||||
- 移除输出中的调试信息
|
||||
@@ -1,674 +0,0 @@
|
||||
"""
|
||||
title: Deep Reading & Summary
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.1.2
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAxMmgtNSIvPjxwYXRoIGQ9Ik0xNSA4aC01Ii8+PHBhdGggZD0iTTE5IDE3VjVhMiAyIDAgMCAwLTItMkg0Ii8+PHBhdGggZD0iTTggMjFoMTJhMiAyIDAgMCAwIDItMnYtMWExIDEgMCAwIDAtMS0xSDExYTEgMSAwIDAgMC0xIDF2MWEyIDIgMCAxIDEtNCAwVjVhMiAyIDAgMSAwLTQgMHYyYTEgMSAwIDAgMCAxIDFoMyIvPjwvc3ZnPg==
|
||||
description: Provides deep reading analysis and summarization for long texts.
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# HTML Wrapper Template (supports multiple plugins and grid layout)
|
||||
# =================================================================
|
||||
HTML_WRAPPER_TEMPLATE = """
|
||||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.plugin-item {
|
||||
flex: 1 1 400px; /* Default width, allows shrinking/growing */
|
||||
min-width: 300px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.plugin-item:hover {
|
||||
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.plugin-item { flex: 1 1 100%; }
|
||||
}
|
||||
/* STYLES_INSERTION_POINT */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<!-- CONTENT_INSERTION_POINT -->
|
||||
</div>
|
||||
<!-- SCRIPTS_INSERTION_POINT -->
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# Internal LLM Prompts
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_READING_ASSISTANT = """
|
||||
You are a professional Deep Text Analysis Expert, specializing in reading long texts and extracting the essence. Your task is to conduct a comprehensive and in-depth analysis.
|
||||
|
||||
Please provide the following:
|
||||
1. **Detailed Summary**: Summarize the core content of the text in 2-3 paragraphs, ensuring accuracy and completeness. Do not be too brief; ensure the reader fully understands the main idea.
|
||||
2. **Key Information Points**: List 5-8 most important facts, viewpoints, or arguments. Each point should:
|
||||
- Be specific and insightful
|
||||
- Include necessary details and context
|
||||
- Use Markdown list format
|
||||
3. **Actionable Advice**: Identify and refine specific, actionable items from the text. Each suggestion should:
|
||||
- Be clear and actionable
|
||||
- Include execution priority or timing suggestions
|
||||
- If there are no clear action items, provide learning suggestions or thinking directions
|
||||
|
||||
Please strictly follow these guidelines:
|
||||
- **Language**: All output must be in the user's specified language.
|
||||
- **Format**: Please strictly follow the Markdown format below, ensuring each section has a clear header:
|
||||
## Summary
|
||||
[Detailed summary content here, 2-3 paragraphs, use Markdown **bold** or *italic* to emphasize key points]
|
||||
|
||||
## Key Information Points
|
||||
- [Key Point 1: Include specific details and context]
|
||||
- [Key Point 2: Include specific details and context]
|
||||
- [Key Point 3: Include specific details and context]
|
||||
- [At least 5, at most 8 key points]
|
||||
|
||||
## Actionable Advice
|
||||
- [Action Item 1: Specific, actionable, include priority]
|
||||
- [Action Item 2: Specific, actionable, include priority]
|
||||
- [If no clear action items, provide learning suggestions or thinking directions]
|
||||
- **Depth First**: Analysis should be deep and comprehensive, not superficial.
|
||||
- **Action Oriented**: Focus on actionable suggestions and next steps.
|
||||
- **Analysis Results Only**: Do not include any extra pleasantries, explanations, or leading text.
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_SUMMARY = """
|
||||
Please conduct a deep analysis of the following long text, providing:
|
||||
1. Detailed Summary (2-3 paragraphs, comprehensive overview)
|
||||
2. Key Information Points List (5-8 items, including specific details)
|
||||
3. Actionable Advice (Specific, clear, including priority)
|
||||
|
||||
---
|
||||
**User Context:**
|
||||
User Name: {user_name}
|
||||
Current Date/Time: {current_date_time_str}
|
||||
Weekday: {current_weekday}
|
||||
Timezone: {current_timezone_str}
|
||||
User Language: {user_language}
|
||||
---
|
||||
|
||||
**Long Text Content:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
Please conduct a deep and comprehensive analysis, focusing on actionable advice.
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# Frontend HTML Template (Jinja2 Syntax)
|
||||
# =================================================================
|
||||
|
||||
CSS_TEMPLATE_SUMMARY = """
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #1e88e5;
|
||||
--action-color: #34a853;
|
||||
--background-color: #f8f9fa;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #202124;
|
||||
--muted-text-color: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
|
||||
--shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
--border-radius: 8px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
.summary-container-wrapper {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.summary-container-wrapper .header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 20px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-container-wrapper .header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.summary-container-wrapper .user-context {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #f1f3f4;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.summary-container-wrapper .user-context span { margin: 2px 8px; }
|
||||
.summary-container-wrapper .content { padding: 20px; flex-grow: 1; }
|
||||
.summary-container-wrapper .section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
}
|
||||
.summary-container-wrapper .section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.summary-container-wrapper .section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
.summary-container-wrapper .section h2 .icon {
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
}
|
||||
.summary-container-wrapper .summary-section h2 { border-bottom-color: var(--primary-color); }
|
||||
.summary-container-wrapper .keypoints-section h2 { border-bottom-color: var(--secondary-color); }
|
||||
.summary-container-wrapper .actions-section h2 { border-bottom-color: var(--action-color); }
|
||||
.summary-container-wrapper .html-content {
|
||||
font-size: 0.95em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.summary-container-wrapper .html-content p:first-child { margin-top: 0; }
|
||||
.summary-container-wrapper .html-content p:last-child { margin-bottom: 0; }
|
||||
.summary-container-wrapper .html-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.summary-container-wrapper .html-content li {
|
||||
padding: 8px 0 8px 24px;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.summary-container-wrapper .html-content li::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
font-family: 'Arial';
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
.summary-container-wrapper .keypoints-section .html-content li::before {
|
||||
content: '•';
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.3em;
|
||||
top: 5px;
|
||||
}
|
||||
.summary-container-wrapper .actions-section .html-content li::before {
|
||||
content: '▸';
|
||||
color: var(--action-color);
|
||||
}
|
||||
.summary-container-wrapper .no-content {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.summary-container-wrapper .footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 0.8em;
|
||||
color: #5f6368;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
"""
|
||||
|
||||
CONTENT_TEMPLATE_SUMMARY = """
|
||||
<div class="summary-container-wrapper">
|
||||
<div class="header">
|
||||
<h1>📖 Deep Reading: Analysis Report</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>User:</strong> {user_name}</span>
|
||||
<span><strong>Time:</strong> {current_date_time_str}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section summary-section">
|
||||
<h2><span class="icon">📝</span>Detailed Summary</h2>
|
||||
<div class="html-content">{summary_html}</div>
|
||||
</div>
|
||||
<div class="section keypoints-section">
|
||||
<h2><span class="icon">💡</span>Key Information Points</h2>
|
||||
<div class="html-content">{keypoints_html}</div>
|
||||
</div>
|
||||
<div class="section actions-section">
|
||||
<h2><span class="icon">🎯</span>Actionable Advice</h2>
|
||||
<div class="html-content">{actions_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {current_year} Deep Reading - Text Analysis Service</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True,
|
||||
description="Whether to show operation status updates in the chat interface.",
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="Built-in LLM Model ID used for text analysis. If empty, uses the current conversation's model.",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200,
|
||||
description="Minimum text length required for deep analysis (characters). Recommended 200+.",
|
||||
)
|
||||
RECOMMENDED_MIN_LENGTH: int = Field(
|
||||
default=500,
|
||||
description="Recommended minimum text length for best analysis results.",
|
||||
)
|
||||
CLEAR_PREVIOUS_HTML: bool = Field(
|
||||
default=False,
|
||||
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""
|
||||
Parse LLM Markdown output and convert to HTML fragments.
|
||||
"""
|
||||
summary_match = re.search(
|
||||
r"##\s*Summary\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
keypoints_match = re.search(
|
||||
r"##\s*Key Information Points\s*\n(.*?)(?=\n##|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
actions_match = re.search(
|
||||
r"##\s*Actionable Advice\s*\n(.*?)(?=\n##|$)",
|
||||
llm_output,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
summary_md = summary_match.group(1).strip() if summary_match else ""
|
||||
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
|
||||
actions_md = actions_match.group(1).strip() if actions_match else ""
|
||||
|
||||
if not any([summary_md, keypoints_md, actions_md]):
|
||||
summary_md = llm_output.strip()
|
||||
logger.warning(
|
||||
"LLM output did not follow expected Markdown format. Treating entire output as summary."
|
||||
)
|
||||
|
||||
# Use 'nl2br' extension to convert newlines \n to <br>
|
||||
md_extensions = ["nl2br"]
|
||||
summary_html = (
|
||||
markdown.markdown(summary_md, extensions=md_extensions)
|
||||
if summary_md
|
||||
else '<p class="no-content">Failed to extract summary.</p>'
|
||||
)
|
||||
keypoints_html = (
|
||||
markdown.markdown(keypoints_md, extensions=md_extensions)
|
||||
if keypoints_md
|
||||
else '<p class="no-content">Failed to extract key information points.</p>'
|
||||
)
|
||||
actions_html = (
|
||||
markdown.markdown(actions_md, extensions=md_extensions)
|
||||
if actions_md
|
||||
else '<p class="no-content">No explicit actionable advice.</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"summary_html": summary_html,
|
||||
"keypoints_html": keypoints_html,
|
||||
"actions_html": actions_html,
|
||||
}
|
||||
|
||||
async def _emit_status(self, emitter, description: str, done: bool = False):
|
||||
"""Emits a status update event."""
|
||||
if self.valves.SHOW_STATUS and emitter:
|
||||
await emitter(
|
||||
{"type": "status", "data": {"description": description, "done": done}}
|
||||
)
|
||||
|
||||
async def _emit_notification(self, emitter, content: str, ntype: str = "info"):
|
||||
"""Emits a notification event (info/success/warning/error)."""
|
||||
if emitter:
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": ntype, "content": content}}
|
||||
)
|
||||
|
||||
def _remove_existing_html(self, content: str) -> str:
|
||||
"""Removes existing plugin-generated HTML code blocks from the content."""
|
||||
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
|
||||
return re.sub(pattern, "", content).strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""Extract text from message content, supporting multimodal message formats"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
# Multimodal message: [{"type": "text", "text": "..."}, {"type": "image_url", ...}]
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
def _merge_html(
|
||||
self,
|
||||
existing_html_code: str,
|
||||
new_content: str,
|
||||
new_styles: str = "",
|
||||
new_scripts: str = "",
|
||||
user_language: str = "en-US",
|
||||
) -> str:
|
||||
"""
|
||||
Merges new content into an existing HTML container, or creates a new one.
|
||||
"""
|
||||
if (
|
||||
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
|
||||
and "<!-- CONTENT_INSERTION_POINT -->" in existing_html_code
|
||||
):
|
||||
base_html = existing_html_code
|
||||
base_html = re.sub(r"^```html\s*", "", base_html)
|
||||
base_html = re.sub(r"\s*```$", "", base_html)
|
||||
else:
|
||||
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
|
||||
|
||||
wrapped_content = f'<div class="plugin-item">\n{new_content}\n</div>'
|
||||
|
||||
if new_styles:
|
||||
base_html = base_html.replace(
|
||||
"/* STYLES_INSERTION_POINT */",
|
||||
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
||||
)
|
||||
|
||||
base_html = base_html.replace(
|
||||
"<!-- CONTENT_INSERTION_POINT -->",
|
||||
f"{wrapped_content}\n<!-- CONTENT_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
if new_scripts:
|
||||
base_html = base_html.replace(
|
||||
"<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def _build_content_html(self, context: dict) -> str:
|
||||
"""
|
||||
Build content HTML using context data.
|
||||
"""
|
||||
return (
|
||||
CONTENT_TEMPLATE_SUMMARY.replace(
|
||||
"{user_name}", context.get("user_name", "User")
|
||||
)
|
||||
.replace(
|
||||
"{current_date_time_str}", context.get("current_date_time_str", "")
|
||||
)
|
||||
.replace("{current_year}", context.get("current_year", ""))
|
||||
.replace("{summary_html}", context.get("summary_html", ""))
|
||||
.replace("{keypoints_html}", context.get("keypoints_html", ""))
|
||||
.replace("{actions_html}", context.get("actions_html", ""))
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: Deep Reading Started (v2.0.0)")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "en-US") if __user__ else "en-US"
|
||||
)
|
||||
user_name = __user__[0].get("name", "User") if __user__[0] else "User"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "en-US")
|
||||
user_name = __user__.get("name", "User")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%B %d, %Y %H:%M:%S")
|
||||
current_weekday = now.strftime("%A")
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "Unknown Timezone"
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("Unable to get valid user message content.")
|
||||
|
||||
# Get last N messages based on MESSAGE_COUNT
|
||||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||||
recent_messages = messages[-message_count:]
|
||||
|
||||
# Aggregate content from selected messages with labels
|
||||
aggregated_parts = []
|
||||
for i, msg in enumerate(recent_messages, 1):
|
||||
text_content = self._extract_text_content(msg.get("content"))
|
||||
if text_content:
|
||||
role = msg.get("role", "unknown")
|
||||
role_label = (
|
||||
"User"
|
||||
if role == "user"
|
||||
else "Assistant" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("Unable to get valid user message content.")
|
||||
|
||||
original_content = "\n\n---\n\n".join(aggregated_parts)
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"Text content too short ({len(original_content)} chars), recommended at least {self.valves.MIN_TEXT_LENGTH} chars for effective deep analysis.\n\n💡 Tip: For short texts, consider using '⚡ Flash Card' for quick refinement."
|
||||
await self._emit_notification(
|
||||
__event_emitter__, short_text_message, "warning"
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
# Recommend for longer texts
|
||||
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"Text length is {len(original_content)} chars. Recommended {self.valves.RECOMMENDED_MIN_LENGTH}+ chars for best analysis results.",
|
||||
"info",
|
||||
)
|
||||
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
"📖 Deep Reading started, analyzing deeply...",
|
||||
"info",
|
||||
)
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
"📖 Deep Reading: Analyzing text, extracting essence...",
|
||||
False,
|
||||
)
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
# Determine model to use
|
||||
target_model = self.valves.MODEL_ID
|
||||
if not target_model:
|
||||
target_model = body.get("model")
|
||||
|
||||
llm_payload = {
|
||||
"model": target_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"Unable to get user object, User ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
|
||||
processed_content = self._process_llm_output(assistant_response_content)
|
||||
|
||||
context = {
|
||||
"user_language": user_language,
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"current_weekday": current_weekday,
|
||||
"current_year": current_year,
|
||||
**processed_content,
|
||||
}
|
||||
|
||||
content_html = self._build_content_html(context)
|
||||
|
||||
# Extract existing HTML if any
|
||||
existing_html_block = ""
|
||||
match = re.search(
|
||||
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
||||
original_content,
|
||||
)
|
||||
if match:
|
||||
existing_html_block = match.group(1)
|
||||
|
||||
if self.valves.CLEAR_PREVIOUS_HTML:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
|
||||
)
|
||||
else:
|
||||
if existing_html_block:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
existing_html_block,
|
||||
content_html,
|
||||
CSS_TEMPLATE_SUMMARY,
|
||||
"",
|
||||
user_language,
|
||||
)
|
||||
else:
|
||||
final_html = self._merge_html(
|
||||
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html}\n```"
|
||||
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "📖 Deep Reading: Analysis complete!", True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"📖 Deep Reading complete, {user_name}! Deep analysis report generated.",
|
||||
"success",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Deep Reading processing failed: {str(e)}"
|
||||
logger.error(f"Deep Reading Error: {error_message}", exc_info=True)
|
||||
user_facing_error = f"Sorry, Deep Reading encountered an error while processing: {str(e)}.\nPlease check Open WebUI backend logs for more details."
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **Error:** {user_facing_error}"
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "Deep Reading: Processing failed.", True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"Deep Reading processing failed, {user_name}!",
|
||||
"error",
|
||||
)
|
||||
|
||||
return body
|
||||
@@ -1,663 +0,0 @@
|
||||
"""
|
||||
title: 精读 (Deep Reading)
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNDI4NWY0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWU4OGU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTE0IDJsNiA2aC02eiIgZmlsbD0iIzFlODhlNSIgb3BhY2l0eT0iMC42Ii8+PGxpbmUgeDE9IjgiIHkxPSIxMyIgeDI9IjE2IiB5Mj0iMTMiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48bGluZSB4MT0iOCIgeTE9IjE3IiB4Mj0iMTQiIHkyPSIxNyIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiNmZmQ3MDAiLz48cGF0aCBkPSJNMTYgMTZsMS41IDEuNSIgc3Ryb2tlPSIjNDI4NWY0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==
|
||||
version: 0.1.2
|
||||
description: 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议,适合工作和学习场景。
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from fastapi import Request
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.models.users import Users
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =================================================================
|
||||
# HTML 容器模板 (支持多插件共存与网格布局)
|
||||
# =================================================================
|
||||
HTML_WRAPPER_TEMPLATE = """
|
||||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="{user_language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.plugin-item {
|
||||
flex: 1 1 400px; /* 默认宽度,允许伸缩 */
|
||||
min-width: 300px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.plugin-item:hover {
|
||||
box-shadow: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.plugin-item { flex: 1 1 100%; }
|
||||
}
|
||||
/* STYLES_INSERTION_POINT */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<!-- CONTENT_INSERTION_POINT -->
|
||||
</div>
|
||||
<!-- SCRIPTS_INSERTION_POINT -->
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 内部 LLM 提示词设计
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_READING_ASSISTANT = """
|
||||
你是一个专业的深度文本分析专家,擅长精读长篇文本并提炼精华。你的任务是进行全面、深入的分析。
|
||||
|
||||
请提供以下内容:
|
||||
1. **详细摘要**:用 2-3 段话全面总结文本的核心内容,确保准确性和完整性。不要过于简略,要让读者充分理解文本主旨。
|
||||
2. **关键信息点**:列出 5-8 个最重要的事实、观点或论据。每个信息点应该:
|
||||
- 具体且有深度
|
||||
- 包含必要的细节和背景
|
||||
- 使用 Markdown 列表格式
|
||||
3. **行动建议**:从文本中识别并提炼出具体的、可执行的行动项。每个建议应该:
|
||||
- 明确且可操作
|
||||
- 包含执行的优先级或时间建议
|
||||
- 如果没有明确的行动项,可以提供学习建议或思考方向
|
||||
|
||||
请严格遵循以下指导原则:
|
||||
- **语言**:所有输出必须使用用户指定的语言。
|
||||
- **格式**:请严格按照以下 Markdown 格式输出,确保每个部分都有明确的标题:
|
||||
## 摘要
|
||||
[这里是详细的摘要内容,2-3段话,可以使用 Markdown 进行**加粗**或*斜体*强调重点]
|
||||
|
||||
## 关键信息点
|
||||
- [关键点1:包含具体细节和背景]
|
||||
- [关键点2:包含具体细节和背景]
|
||||
- [关键点3:包含具体细节和背景]
|
||||
- [至少5个,最多8个关键点]
|
||||
|
||||
## 行动建议
|
||||
- [行动项1:具体、可执行,包含优先级]
|
||||
- [行动项2:具体、可执行,包含优先级]
|
||||
- [如果没有明确行动项,提供学习建议或思考方向]
|
||||
- **深度优先**:分析要深入、全面,不要浮于表面。
|
||||
- **行动导向**:重点关注可执行的建议和下一步行动。
|
||||
- **只输出分析结果**:不要包含任何额外的寒暄、解释或引导性文字。
|
||||
"""
|
||||
|
||||
USER_PROMPT_GENERATE_SUMMARY = """
|
||||
请对以下长篇文本进行深度分析,提供:
|
||||
1. 详细的摘要(2-3段话,全面概括文本内容)
|
||||
2. 关键信息点列表(5-8个,包含具体细节)
|
||||
3. 可执行的行动建议(具体、明确,包含优先级)
|
||||
|
||||
---
|
||||
**用户上下文信息:**
|
||||
用户姓名: {user_name}
|
||||
当前日期时间: {current_date_time_str}
|
||||
当前星期: {current_weekday}
|
||||
当前时区: {current_timezone_str}
|
||||
用户语言: {user_language}
|
||||
---
|
||||
|
||||
**长篇文本内容:**
|
||||
```
|
||||
{long_text_content}
|
||||
```
|
||||
|
||||
请进行深入、全面的分析,重点关注可执行的行动建议。
|
||||
"""
|
||||
|
||||
# =================================================================
|
||||
# 前端 HTML 模板 (Jinja2 语法)
|
||||
# =================================================================
|
||||
|
||||
CSS_TEMPLATE_SUMMARY = """
|
||||
:root {
|
||||
--primary-color: #4285f4;
|
||||
--secondary-color: #1e88e5;
|
||||
--action-color: #34a853;
|
||||
--background-color: #f8f9fa;
|
||||
--card-bg-color: #ffffff;
|
||||
--text-color: #202124;
|
||||
--muted-text-color: #5f6368;
|
||||
--border-color: #dadce0;
|
||||
--header-gradient: linear-gradient(135deg, #4285f4, #1e88e5);
|
||||
--shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
--border-radius: 8px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
.summary-container-wrapper {
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.summary-container-wrapper .header {
|
||||
background: var(--header-gradient);
|
||||
color: white;
|
||||
padding: 20px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-container-wrapper .header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.summary-container-wrapper .user-context {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-text-color);
|
||||
background-color: #f1f3f4;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.summary-container-wrapper .user-context span { margin: 2px 8px; }
|
||||
.summary-container-wrapper .content { padding: 20px; flex-grow: 1; }
|
||||
.summary-container-wrapper .section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e8eaed;
|
||||
}
|
||||
.summary-container-wrapper .section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.summary-container-wrapper .section h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
.summary-container-wrapper .section h2 .icon {
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
}
|
||||
.summary-container-wrapper .summary-section h2 { border-bottom-color: var(--primary-color); }
|
||||
.summary-container-wrapper .keypoints-section h2 { border-bottom-color: var(--secondary-color); }
|
||||
.summary-container-wrapper .actions-section h2 { border-bottom-color: var(--action-color); }
|
||||
.summary-container-wrapper .html-content {
|
||||
font-size: 0.95em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.summary-container-wrapper .html-content p:first-child { margin-top: 0; }
|
||||
.summary-container-wrapper .html-content p:last-child { margin-bottom: 0; }
|
||||
.summary-container-wrapper .html-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.summary-container-wrapper .html-content li {
|
||||
padding: 8px 0 8px 24px;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.summary-container-wrapper .html-content li::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
font-family: 'Arial';
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
.summary-container-wrapper .keypoints-section .html-content li::before {
|
||||
content: '•';
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.3em;
|
||||
top: 5px;
|
||||
}
|
||||
.summary-container-wrapper .actions-section .html-content li::before {
|
||||
content: '▸';
|
||||
color: var(--action-color);
|
||||
}
|
||||
.summary-container-wrapper .no-content {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.summary-container-wrapper .footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 0.8em;
|
||||
color: #5f6368;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
"""
|
||||
|
||||
CONTENT_TEMPLATE_SUMMARY = """
|
||||
<div class="summary-container-wrapper">
|
||||
<div class="header">
|
||||
<h1>📖 精读:深度分析报告</h1>
|
||||
</div>
|
||||
<div class="user-context">
|
||||
<span><strong>用户:</strong> {user_name}</span>
|
||||
<span><strong>时间:</strong> {current_date_time_str}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="section summary-section">
|
||||
<h2><span class="icon">📝</span>详细摘要</h2>
|
||||
<div class="html-content">{summary_html}</div>
|
||||
</div>
|
||||
<div class="section keypoints-section">
|
||||
<h2><span class="icon">💡</span>关键信息点</h2>
|
||||
<div class="html-content">{keypoints_html}</div>
|
||||
</div>
|
||||
<div class="section actions-section">
|
||||
<h2><span class="icon">🎯</span>行动建议</h2>
|
||||
<div class="html-content">{actions_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {current_year} 精读 - 深度文本分析服务</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
SHOW_STATUS: bool = Field(
|
||||
default=True, description="是否在聊天界面显示操作状态更新。"
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=200,
|
||||
description="进行深度分析所需的最小文本长度(字符数)。建议200字符以上。",
|
||||
)
|
||||
RECOMMENDED_MIN_LENGTH: int = Field(
|
||||
default=500, description="建议的最小文本长度,以获得最佳分析效果。"
|
||||
)
|
||||
CLEAR_PREVIOUS_HTML: bool = Field(
|
||||
default=False,
|
||||
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self.weekday_map = {
|
||||
"Monday": "星期一",
|
||||
"Tuesday": "星期二",
|
||||
"Wednesday": "星期三",
|
||||
"Thursday": "星期四",
|
||||
"Friday": "星期五",
|
||||
"Saturday": "星期六",
|
||||
"Sunday": "星期日",
|
||||
}
|
||||
|
||||
def _process_llm_output(self, llm_output: str) -> Dict[str, str]:
|
||||
"""
|
||||
解析LLM的Markdown输出,将其转换为HTML片段。
|
||||
"""
|
||||
summary_match = re.search(
|
||||
r"##\s*摘要\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
keypoints_match = re.search(
|
||||
r"##\s*关键信息点\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
actions_match = re.search(
|
||||
r"##\s*行动建议\s*\n(.*?)(?=\n##|$)", llm_output, re.DOTALL
|
||||
)
|
||||
|
||||
summary_md = summary_match.group(1).strip() if summary_match else ""
|
||||
keypoints_md = keypoints_match.group(1).strip() if keypoints_match else ""
|
||||
actions_md = actions_match.group(1).strip() if actions_match else ""
|
||||
|
||||
if not any([summary_md, keypoints_md, actions_md]):
|
||||
summary_md = llm_output.strip()
|
||||
logger.warning("LLM输出未遵循预期的Markdown格式。将整个输出视为摘要。")
|
||||
|
||||
# 使用 'nl2br' 扩展将换行符 \n 转换为 <br>
|
||||
md_extensions = ["nl2br"]
|
||||
summary_html = (
|
||||
markdown.markdown(summary_md, extensions=md_extensions)
|
||||
if summary_md
|
||||
else '<p class="no-content">未能提取摘要信息。</p>'
|
||||
)
|
||||
keypoints_html = (
|
||||
markdown.markdown(keypoints_md, extensions=md_extensions)
|
||||
if keypoints_md
|
||||
else '<p class="no-content">未能提取关键信息点。</p>'
|
||||
)
|
||||
actions_html = (
|
||||
markdown.markdown(actions_md, extensions=md_extensions)
|
||||
if actions_md
|
||||
else '<p class="no-content">暂无明确的行动建议。</p>'
|
||||
)
|
||||
|
||||
return {
|
||||
"summary_html": summary_html,
|
||||
"keypoints_html": keypoints_html,
|
||||
"actions_html": actions_html,
|
||||
}
|
||||
|
||||
async def _emit_status(self, emitter, description: str, done: bool = False):
|
||||
"""发送状态更新事件。"""
|
||||
if self.valves.SHOW_STATUS and emitter:
|
||||
await emitter(
|
||||
{"type": "status", "data": {"description": description, "done": done}}
|
||||
)
|
||||
|
||||
async def _emit_notification(self, emitter, content: str, ntype: str = "info"):
|
||||
"""发送通知事件 (info/success/warning/error)。"""
|
||||
if emitter:
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": ntype, "content": content}}
|
||||
)
|
||||
|
||||
def _remove_existing_html(self, content: str) -> str:
|
||||
"""移除内容中已有的插件生成 HTML 代码块 (通过标记识别)。"""
|
||||
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
|
||||
return re.sub(pattern, "", content).strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""从消息内容中提取文本,支持多模态消息格式"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
# 多模态消息: [{"type": "text", "text": "..."}, {"type": "image_url", ...}]
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
return "\n".join(text_parts)
|
||||
return str(content) if content else ""
|
||||
|
||||
def _merge_html(
|
||||
self,
|
||||
existing_html_code: str,
|
||||
new_content: str,
|
||||
new_styles: str = "",
|
||||
new_scripts: str = "",
|
||||
user_language: str = "zh-CN",
|
||||
) -> str:
|
||||
"""
|
||||
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
|
||||
"""
|
||||
if (
|
||||
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
|
||||
and "<!-- CONTENT_INSERTION_POINT -->" in existing_html_code
|
||||
):
|
||||
base_html = existing_html_code
|
||||
base_html = re.sub(r"^```html\s*", "", base_html)
|
||||
base_html = re.sub(r"\s*```$", "", base_html)
|
||||
else:
|
||||
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
|
||||
|
||||
wrapped_content = f'<div class="plugin-item">\n{new_content}\n</div>'
|
||||
|
||||
if new_styles:
|
||||
base_html = base_html.replace(
|
||||
"/* STYLES_INSERTION_POINT */",
|
||||
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
||||
)
|
||||
|
||||
base_html = base_html.replace(
|
||||
"<!-- CONTENT_INSERTION_POINT -->",
|
||||
f"{wrapped_content}\n<!-- CONTENT_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
if new_scripts:
|
||||
base_html = base_html.replace(
|
||||
"<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
|
||||
)
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def _build_content_html(self, context: dict) -> str:
|
||||
"""
|
||||
使用上下文数据构建内容 HTML。
|
||||
"""
|
||||
return (
|
||||
CONTENT_TEMPLATE_SUMMARY.replace(
|
||||
"{user_name}", context.get("user_name", "用户")
|
||||
)
|
||||
.replace(
|
||||
"{current_date_time_str}", context.get("current_date_time_str", "")
|
||||
)
|
||||
.replace("{current_year}", context.get("current_year", ""))
|
||||
.replace("{summary_html}", context.get("summary_html", ""))
|
||||
.replace("{keypoints_html}", context.get("keypoints_html", ""))
|
||||
.replace("{actions_html}", context.get("actions_html", ""))
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 精读启动 (v2.0.0 - Deep Reading)")
|
||||
|
||||
if isinstance(__user__, (list, tuple)):
|
||||
user_language = (
|
||||
__user__[0].get("language", "zh-CN") if __user__ else "zh-CN"
|
||||
)
|
||||
user_name = __user__[0].get("name", "用户") if __user__[0] else "用户"
|
||||
user_id = (
|
||||
__user__[0]["id"]
|
||||
if __user__ and "id" in __user__[0]
|
||||
else "unknown_user"
|
||||
)
|
||||
elif isinstance(__user__, dict):
|
||||
user_language = __user__.get("language", "zh-CN")
|
||||
user_name = __user__.get("name", "用户")
|
||||
user_id = __user__.get("id", "unknown_user")
|
||||
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M:%S")
|
||||
current_weekday_en = now.strftime("%A")
|
||||
current_weekday = self.weekday_map.get(current_weekday_en, current_weekday_en)
|
||||
current_year = now.strftime("%Y")
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
original_content = ""
|
||||
try:
|
||||
messages = body.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
|
||||
# Get last N messages based on MESSAGE_COUNT
|
||||
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
||||
recent_messages = messages[-message_count:]
|
||||
|
||||
# Aggregate content from selected messages with labels
|
||||
aggregated_parts = []
|
||||
for i, msg in enumerate(recent_messages, 1):
|
||||
text_content = self._extract_text_content(msg.get("content"))
|
||||
if text_content:
|
||||
role = msg.get("role", "unknown")
|
||||
role_label = (
|
||||
"用户"
|
||||
if role == "user"
|
||||
else "助手" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
|
||||
original_content = "\n\n---\n\n".join(aggregated_parts)
|
||||
|
||||
if len(original_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(original_content)}字符),建议至少{self.valves.MIN_TEXT_LENGTH}字符以获得有效的深度分析。\n\n💡 提示:对于短文本,建议使用'⚡ 闪记卡'进行快速提炼。"
|
||||
await self._emit_notification(
|
||||
__event_emitter__, short_text_message, "warning"
|
||||
)
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||||
]
|
||||
}
|
||||
|
||||
# Recommend for longer texts
|
||||
if len(original_content) < self.valves.RECOMMENDED_MIN_LENGTH:
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"文本长度为{len(original_content)}字符。建议{self.valves.RECOMMENDED_MIN_LENGTH}字符以上可获得更好的分析效果。",
|
||||
"info",
|
||||
)
|
||||
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "📖 精读已启动,正在进行深度分析...", "info"
|
||||
)
|
||||
await self._emit_status(
|
||||
__event_emitter__, "📖 精读: 深入分析文本,提炼精华...", False
|
||||
)
|
||||
|
||||
formatted_user_prompt = USER_PROMPT_GENERATE_SUMMARY.format(
|
||||
user_name=user_name,
|
||||
current_date_time_str=current_date_time_str,
|
||||
current_weekday=current_weekday,
|
||||
current_timezone_str=current_timezone_str,
|
||||
user_language=user_language,
|
||||
long_text_content=original_content,
|
||||
)
|
||||
|
||||
# 确定使用的模型
|
||||
target_model = self.valves.MODEL_ID
|
||||
if not target_model:
|
||||
target_model = body.get("model")
|
||||
|
||||
llm_payload = {
|
||||
"model": target_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_READING_ASSISTANT},
|
||||
{"role": "user", "content": formatted_user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象, 用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
)
|
||||
assistant_response_content = llm_response["choices"][0]["message"][
|
||||
"content"
|
||||
]
|
||||
|
||||
processed_content = self._process_llm_output(assistant_response_content)
|
||||
|
||||
context = {
|
||||
"user_language": user_language,
|
||||
"user_name": user_name,
|
||||
"current_date_time_str": current_date_time_str,
|
||||
"current_weekday": current_weekday,
|
||||
"current_year": current_year,
|
||||
**processed_content,
|
||||
}
|
||||
|
||||
content_html = self._build_content_html(context)
|
||||
|
||||
# Extract existing HTML if any
|
||||
existing_html_block = ""
|
||||
match = re.search(
|
||||
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
||||
original_content,
|
||||
)
|
||||
if match:
|
||||
existing_html_block = match.group(1)
|
||||
|
||||
if self.valves.CLEAR_PREVIOUS_HTML:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
|
||||
)
|
||||
else:
|
||||
if existing_html_block:
|
||||
original_content = self._remove_existing_html(original_content)
|
||||
final_html = self._merge_html(
|
||||
existing_html_block,
|
||||
content_html,
|
||||
CSS_TEMPLATE_SUMMARY,
|
||||
"",
|
||||
user_language,
|
||||
)
|
||||
else:
|
||||
final_html = self._merge_html(
|
||||
"", content_html, CSS_TEMPLATE_SUMMARY, "", user_language
|
||||
)
|
||||
|
||||
html_embed_tag = f"```html\n{final_html}\n```"
|
||||
body["messages"][-1]["content"] = f"{original_content}\n\n{html_embed_tag}"
|
||||
|
||||
await self._emit_status(__event_emitter__, "📖 精读: 分析完成!", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"📖 精读完成,{user_name}!深度分析报告已生成。",
|
||||
"success",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"精读处理失败: {str(e)}"
|
||||
logger.error(f"精读错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉, 精读在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{original_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
await self._emit_status(__event_emitter__, "精读: 处理失败。", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, f"精读处理失败, {user_name}!", "error"
|
||||
)
|
||||
|
||||
return body
|
||||
@@ -1,15 +1,26 @@
|
||||
# Async Context Compression Filter
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.1.0 | **License:** MIT
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 1.1.3 | **License:** MIT
|
||||
|
||||
This filter reduces token consumption in long conversations through intelligent summarization and message compression while keeping conversations coherent.
|
||||
|
||||
## What's new in 1.1.0
|
||||
## What's new in 1.1.3
|
||||
- **Improved Compatibility**: Changed summary injection role from `user` to `assistant` for better compatibility across different LLMs.
|
||||
- **Enhanced Stability**: Fixed a race condition in state management that could cause "inlet state not found" warnings in high-concurrency scenarios.
|
||||
- **Bug Fixes**: Corrected default model handling to prevent misleading logs when no model is specified.
|
||||
|
||||
## What's new in 1.1.2
|
||||
|
||||
- **Open WebUI v0.7.x Compatibility**: Resolved a critical database session binding error affecting Open WebUI v0.7.x users. The plugin now dynamically discovers the database engine and session context, ensuring compatibility across versions.
|
||||
- **Enhanced Error Reporting**: Errors during background summary generation are now reported via both the status bar and browser console.
|
||||
- **Robust Model Handling**: Improved handling of missing or invalid model IDs to prevent crashes.
|
||||
|
||||
## What's new in 1.1.1
|
||||
|
||||
- **Frontend Debugging**: Added `show_debug_log` option to print debug info to the browser console (F12).
|
||||
- **Optimized Compression**: Improved token calculation logic to prevent aggressive truncation of history, ensuring more context is retained.
|
||||
|
||||
|
||||
- Reuses Open WebUI's shared database connection by default (no custom engine or env vars required).
|
||||
- Token-based thresholds (`compression_threshold_tokens`, `max_context_tokens`) for safer long-context handling.
|
||||
- Per-model overrides via `model_thresholds` for mixed-model workflows.
|
||||
- Documentation now mirrors the latest async workflow and retention-first injection.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,6 +65,7 @@ It is recommended to keep this filter early in the chain so it runs before filte
|
||||
| `summary_temperature` | `0.3` | Randomness for summary generation. Lower is more deterministic. |
|
||||
| `model_thresholds` | `{}` | Per-model overrides for `compression_threshold_tokens` and `max_context_tokens` (useful for mixed models). |
|
||||
| `debug_mode` | `true` | Log verbose debug info. Set to `false` in production. |
|
||||
| `show_debug_log` | `false` | Print debug logs to browser console (F12). Useful for frontend debugging. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
# 异步上下文压缩过滤器
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.2.0 | **许可证:** MIT
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 1.1.3 | **许可证:** MIT
|
||||
|
||||
> **重要提示**:为了确保所有过滤器的可维护性和易用性,每个过滤器都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
|
||||
|
||||
本过滤器通过智能摘要和消息压缩技术,在保持对话连贯性的同时,显著降低长对话的 Token 消耗。
|
||||
|
||||
## 1.1.0 版本更新
|
||||
## 1.1.3 版本更新
|
||||
- **兼容性提升**: 将摘要注入角色从 `user` 改为 `assistant`,以提高在不同 LLM 之间的兼容性。
|
||||
- **稳定性增强**: 修复了状态管理中的竞态条件,解决了高并发场景下可能出现的“无法获取 inlet 状态”警告。
|
||||
- **Bug 修复**: 修正了默认模型处理逻辑,防止在未指定模型时产生误导性日志。
|
||||
|
||||
## 1.1.2 版本更新
|
||||
|
||||
- **Open WebUI v0.7.x 兼容性**: 修复了影响 Open WebUI v0.7.x 用户的严重数据库会话绑定错误。插件现在动态发现数据库引擎和会话上下文,确保跨版本兼容性。
|
||||
- **增强错误报告**: 后台摘要生成过程中的错误现在会通过状态栏和浏览器控制台同时报告。
|
||||
- **健壮的模型处理**: 改进了对缺失或无效模型 ID 的处理,防止程序崩溃。
|
||||
|
||||
## 1.1.1 版本更新
|
||||
|
||||
- **前端调试**: 新增 `show_debug_log` 选项,支持在浏览器控制台 (F12) 打印调试信息。
|
||||
- **压缩优化**: 优化 Token 计算逻辑,防止历史记录被过度截断,保留更多上下文。
|
||||
|
||||
|
||||
- 默认复用 OpenWebUI 内置数据库连接,无需自建引擎、无需配置 `DATABASE_URL`。
|
||||
- 基于 Token 的阈值控制(`compression_threshold_tokens`、`max_context_tokens`),长上下文更安全。
|
||||
- 支持 `model_thresholds` 为不同模型设置专属阈值,适合混用多模型场景。
|
||||
- 文档同步最新异步工作流与“先保留再注入”策略。
|
||||
|
||||
---
|
||||
|
||||
@@ -94,6 +105,11 @@
|
||||
- **默认值**: `true`
|
||||
- **描述**: 是否在 Open WebUI 的控制台日志中打印详细的调试信息(如 Token 计数、压缩进度、数据库操作等)。生产环境建议设为 `false`。
|
||||
|
||||
#### `show_debug_log`
|
||||
|
||||
- **默认值**: `false`
|
||||
- **描述**: 是否在浏览器控制台 (F12) 打印调试日志。便于前端调试。
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
@@ -5,7 +5,7 @@ author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
description: Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.
|
||||
version: 1.1.0
|
||||
version: 1.1.3
|
||||
openwebui_id: b1655bc8-6de9-4cad-8cb5-a6f7829a02ce
|
||||
license: MIT
|
||||
|
||||
@@ -139,6 +139,10 @@ debug_mode
|
||||
Default: true
|
||||
Description: Prints detailed debug information to the log. Recommended to set to `false` in production.
|
||||
|
||||
show_debug_log
|
||||
Default: false
|
||||
Description: Print debug logs to browser console (F12). Useful for frontend debugging.
|
||||
|
||||
🔧 Deployment
|
||||
═══════════════════════════════════════════════════════
|
||||
|
||||
@@ -245,6 +249,7 @@ import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import time
|
||||
import contextlib
|
||||
|
||||
# Open WebUI built-in imports
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
@@ -253,9 +258,10 @@ from fastapi.requests import Request
|
||||
from open_webui.main import app as webui_app
|
||||
|
||||
# Open WebUI internal database (re-use shared connection)
|
||||
from open_webui.internal.db import engine as owui_engine
|
||||
from open_webui.internal.db import Session as owui_Session
|
||||
from open_webui.internal.db import Base as owui_Base
|
||||
try:
|
||||
from open_webui.internal import db as owui_db
|
||||
except ModuleNotFoundError: # pragma: no cover - filter runs inside Open WebUI
|
||||
owui_db = None
|
||||
|
||||
# Try to import tiktoken
|
||||
try:
|
||||
@@ -265,14 +271,91 @@ except ImportError:
|
||||
|
||||
# Database imports
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer, inspect
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from sqlalchemy.engine import Engine
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _discover_owui_engine(db_module: Any) -> Optional[Engine]:
|
||||
"""Discover the Open WebUI SQLAlchemy engine via provided db module helpers."""
|
||||
if db_module is None:
|
||||
return None
|
||||
|
||||
db_context = getattr(db_module, "get_db_context", None) or getattr(
|
||||
db_module, "get_db", None
|
||||
)
|
||||
if callable(db_context):
|
||||
try:
|
||||
with db_context() as session:
|
||||
try:
|
||||
return session.get_bind()
|
||||
except AttributeError:
|
||||
return getattr(session, "bind", None) or getattr(
|
||||
session, "engine", None
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[DB Discover] get_db_context failed: {exc}")
|
||||
|
||||
for attr in ("engine", "ENGINE", "bind", "BIND"):
|
||||
candidate = getattr(db_module, attr, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _discover_owui_schema(db_module: Any) -> Optional[str]:
|
||||
"""Discover the Open WebUI database schema name if configured."""
|
||||
if db_module is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
base = getattr(db_module, "Base", None)
|
||||
metadata = getattr(base, "metadata", None) if base is not None else None
|
||||
candidate = getattr(metadata, "schema", None) if metadata is not None else None
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
except Exception as exc:
|
||||
print(f"[DB Discover] Base metadata schema lookup failed: {exc}")
|
||||
|
||||
try:
|
||||
metadata_obj = getattr(db_module, "metadata_obj", None)
|
||||
candidate = (
|
||||
getattr(metadata_obj, "schema", None) if metadata_obj is not None else None
|
||||
)
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
except Exception as exc:
|
||||
print(f"[DB Discover] metadata_obj schema lookup failed: {exc}")
|
||||
|
||||
try:
|
||||
from open_webui import env as owui_env
|
||||
|
||||
candidate = getattr(owui_env, "DATABASE_SCHEMA", None)
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
except Exception as exc:
|
||||
print(f"[DB Discover] env schema lookup failed: {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
owui_engine = _discover_owui_engine(owui_db)
|
||||
owui_schema = _discover_owui_schema(owui_db)
|
||||
owui_Base = getattr(owui_db, "Base", None) if owui_db is not None else None
|
||||
if owui_Base is None:
|
||||
owui_Base = declarative_base()
|
||||
|
||||
|
||||
class ChatSummary(owui_Base):
|
||||
"""Chat Summary Storage Table"""
|
||||
|
||||
__tablename__ = "chat_summary"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
__table_args__ = (
|
||||
{"extend_existing": True, "schema": owui_schema}
|
||||
if owui_schema
|
||||
else {"extend_existing": True}
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
chat_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
@@ -285,14 +368,69 @@ class ChatSummary(owui_Base):
|
||||
class Filter:
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
self._owui_db = owui_db
|
||||
self._db_engine = owui_engine
|
||||
self._SessionLocal = owui_Session
|
||||
self.temp_state = {} # Used to pass temporary data between inlet and outlet
|
||||
self._db_engine = owui_engine
|
||||
self._fallback_session_factory = (
|
||||
sessionmaker(bind=self._db_engine) if self._db_engine else None
|
||||
)
|
||||
self._fallback_session_factory = (
|
||||
sessionmaker(bind=self._db_engine) if self._db_engine else None
|
||||
)
|
||||
self._init_database()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _db_session(self):
|
||||
"""Yield a database session using Open WebUI helpers with graceful fallbacks."""
|
||||
db_module = self._owui_db
|
||||
db_context = None
|
||||
if db_module is not None:
|
||||
db_context = getattr(db_module, "get_db_context", None) or getattr(
|
||||
db_module, "get_db", None
|
||||
)
|
||||
|
||||
if callable(db_context):
|
||||
with db_context() as session:
|
||||
yield session
|
||||
return
|
||||
|
||||
factory = None
|
||||
if db_module is not None:
|
||||
factory = getattr(db_module, "SessionLocal", None) or getattr(
|
||||
db_module, "ScopedSession", None
|
||||
)
|
||||
if callable(factory):
|
||||
session = factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
close = getattr(session, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
return
|
||||
|
||||
if self._fallback_session_factory is None:
|
||||
raise RuntimeError(
|
||||
"Open WebUI database session is unavailable. Ensure Open WebUI's database layer is initialized."
|
||||
)
|
||||
|
||||
session = self._fallback_session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
try:
|
||||
session.close()
|
||||
except Exception as exc: # pragma: no cover - best-effort cleanup
|
||||
print(f"[Database] ⚠️ Failed to close fallback session: {exc}")
|
||||
|
||||
def _init_database(self):
|
||||
"""Initializes the database table using Open WebUI's shared connection."""
|
||||
try:
|
||||
if self._db_engine is None:
|
||||
raise RuntimeError(
|
||||
"Open WebUI database engine is unavailable. Ensure Open WebUI is configured with a valid DATABASE_URL."
|
||||
)
|
||||
|
||||
# Check if table exists using SQLAlchemy inspect
|
||||
inspector = inspect(self._db_engine)
|
||||
if not inspector.has_table("chat_summary"):
|
||||
@@ -355,11 +493,14 @@ class Filter:
|
||||
debug_mode: bool = Field(
|
||||
default=True, description="Enable detailed logging for debugging."
|
||||
)
|
||||
show_debug_log: bool = Field(
|
||||
default=False, description="Print debug logs to browser console (F12)"
|
||||
)
|
||||
|
||||
def _save_summary(self, chat_id: str, summary: str, compressed_count: int):
|
||||
"""Saves the summary to the database."""
|
||||
try:
|
||||
with self._SessionLocal() as session:
|
||||
with self._db_session() as session:
|
||||
# Find existing record
|
||||
existing = session.query(ChatSummary).filter_by(chat_id=chat_id).first()
|
||||
|
||||
@@ -399,7 +540,7 @@ class Filter:
|
||||
def _load_summary_record(self, chat_id: str) -> Optional[ChatSummary]:
|
||||
"""Loads the summary record object from the database."""
|
||||
try:
|
||||
with self._SessionLocal() as session:
|
||||
with self._db_session() as session:
|
||||
record = session.query(ChatSummary).filter_by(chat_id=chat_id).first()
|
||||
if record:
|
||||
# Detach the object from the session so it can be used after session close
|
||||
@@ -480,41 +621,121 @@ class Filter:
|
||||
"max_context_tokens": self.valves.max_context_tokens,
|
||||
}
|
||||
|
||||
def _inject_summary_to_first_message(self, message: dict, summary: str) -> dict:
|
||||
"""Injects the summary into the first message (prepended to content)."""
|
||||
content = message.get("content", "")
|
||||
summary_block = f"【Historical Conversation Summary】\n{summary}\n\n---\nBelow is the recent conversation:\n\n"
|
||||
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()
|
||||
|
||||
# Handle different content types
|
||||
if isinstance(content, list): # Multimodal content
|
||||
# Find the first text part and insert the summary before it
|
||||
new_content = []
|
||||
summary_inserted = False
|
||||
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()
|
||||
|
||||
for part in content:
|
||||
if (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and not summary_inserted
|
||||
):
|
||||
# Prepend summary to the first text part
|
||||
new_content.append(
|
||||
{"type": "text", "text": summary_block + part.get("text", "")}
|
||||
)
|
||||
summary_inserted = True
|
||||
else:
|
||||
new_content.append(part)
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
# If no text part, insert at the beginning
|
||||
if not summary_inserted:
|
||||
new_content.insert(0, {"type": "text", "text": summary_block})
|
||||
return ""
|
||||
|
||||
message["content"] = new_content
|
||||
async def _emit_debug_log(
|
||||
self,
|
||||
__event_call__,
|
||||
chat_id: str,
|
||||
original_count: int,
|
||||
compressed_count: int,
|
||||
summary_length: int,
|
||||
kept_first: int,
|
||||
kept_last: int,
|
||||
):
|
||||
"""Emit debug log to browser console via JS execution"""
|
||||
if not self.valves.show_debug_log or not __event_call__:
|
||||
return
|
||||
|
||||
elif isinstance(content, str): # Plain text
|
||||
message["content"] = summary_block + content
|
||||
try:
|
||||
# Prepare data for JS
|
||||
log_data = {
|
||||
"chatId": chat_id,
|
||||
"originalCount": original_count,
|
||||
"compressedCount": compressed_count,
|
||||
"summaryLength": summary_length,
|
||||
"keptFirst": kept_first,
|
||||
"keptLast": kept_last,
|
||||
"ratio": (
|
||||
f"{(1 - compressed_count/original_count)*100:.1f}%"
|
||||
if original_count > 0
|
||||
else "0%"
|
||||
),
|
||||
}
|
||||
|
||||
return message
|
||||
# Construct JS code
|
||||
js_code = f"""
|
||||
(async function() {{
|
||||
console.group("🗜️ Async Context Compression Debug");
|
||||
console.log("Chat ID:", {json.dumps(chat_id)});
|
||||
console.log("Messages:", {original_count} + " -> " + {compressed_count});
|
||||
console.log("Compression Ratio:", {json.dumps(log_data['ratio'])});
|
||||
console.log("Summary Length:", {summary_length} + " chars");
|
||||
console.log("Configuration:", {{
|
||||
"Keep First": {kept_first},
|
||||
"Keep Last": {kept_last}
|
||||
}});
|
||||
console.groupEnd();
|
||||
}})();
|
||||
"""
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error emitting debug log: {e}")
|
||||
|
||||
async def _log(self, message: str, type: str = "info", event_call=None):
|
||||
"""Unified logging to both backend (print) and frontend (console.log)"""
|
||||
# Backend logging
|
||||
if self.valves.debug_mode:
|
||||
print(message)
|
||||
|
||||
# Frontend logging
|
||||
if self.valves.show_debug_log and event_call:
|
||||
try:
|
||||
css = "color: #3b82f6;" # Blue default
|
||||
if type == "error":
|
||||
css = "color: #ef4444; font-weight: bold;" # Red
|
||||
elif type == "warning":
|
||||
css = "color: #f59e0b;" # Orange
|
||||
elif type == "success":
|
||||
css = "color: #10b981; font-weight: bold;" # Green
|
||||
|
||||
# Clean message for frontend: remove separators and extra newlines
|
||||
lines = message.split("\n")
|
||||
# Keep lines that don't start with lots of equals or hyphens
|
||||
filtered_lines = [
|
||||
line
|
||||
for line in lines
|
||||
if not line.strip().startswith("====")
|
||||
and not line.strip().startswith("----")
|
||||
]
|
||||
clean_message = "\n".join(filtered_lines).strip()
|
||||
|
||||
if not clean_message:
|
||||
return
|
||||
|
||||
# Escape quotes in message for JS string
|
||||
safe_message = clean_message.replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
js_code = f"""
|
||||
console.log("%c[Compression] {safe_message}", "{css}");
|
||||
"""
|
||||
await event_call({"type": "execute", "data": {"code": js_code}})
|
||||
except Exception as e:
|
||||
print(f"Failed to emit log to frontend: {e}")
|
||||
|
||||
async def inlet(
|
||||
self,
|
||||
@@ -522,36 +743,41 @@ class Filter:
|
||||
__user__: Optional[dict] = None,
|
||||
__metadata__: dict = None,
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Executed before sending to the LLM.
|
||||
Compression Strategy: Only responsible for injecting existing summaries, no Token calculation.
|
||||
"""
|
||||
messages = body.get("messages", [])
|
||||
chat_id = __metadata__["chat_id"]
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[Inlet] Chat ID: {chat_id}")
|
||||
print(f"[Inlet] Received {len(messages)} messages")
|
||||
if not chat_id:
|
||||
await self._log(
|
||||
"[Inlet] ❌ Missing chat_id in metadata, skipping compression",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return body
|
||||
|
||||
if self.valves.debug_mode or self.valves.show_debug_log:
|
||||
await self._log(
|
||||
f"\n{'='*60}\n[Inlet] Chat ID: {chat_id}\n[Inlet] Received {len(messages)} messages",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Record the target compression progress for the original messages, for use in outlet
|
||||
# Target is to compress up to the (total - keep_last) message
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
|
||||
# [Optimization] Simple state cleanup check
|
||||
if chat_id in self.temp_state:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[Inlet] ⚠️ Overwriting unconsumed old state (Chat ID: {chat_id})"
|
||||
)
|
||||
# Record the target compression progress for the original messages, for use in outlet
|
||||
# Target is to compress up to the (total - keep_last) message
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
|
||||
self.temp_state[chat_id] = target_compressed_count
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[Inlet] Recorded target compression progress: {target_compressed_count}"
|
||||
)
|
||||
await self._log(
|
||||
f"[Inlet] Recorded target compression progress: {target_compressed_count}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Load summary record
|
||||
summary_record = await asyncio.to_thread(self._load_summary_record, chat_id)
|
||||
@@ -579,7 +805,7 @@ class Filter:
|
||||
f"---\n"
|
||||
f"Below is the recent conversation:"
|
||||
)
|
||||
summary_msg = {"role": "user", "content": summary_content}
|
||||
summary_msg = {"role": "assistant", "content": summary_content}
|
||||
|
||||
# 3. Tail messages (Tail) - All messages starting from the last compression point
|
||||
# Note: Must ensure head messages are not duplicated
|
||||
@@ -600,19 +826,32 @@ class Filter:
|
||||
}
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[Inlet] Applied summary: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})"
|
||||
)
|
||||
await self._log(
|
||||
f"[Inlet] Applied summary: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})",
|
||||
type="success",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Emit debug log to frontend (Keep the structured log as well)
|
||||
await self._emit_debug_log(
|
||||
__event_call__,
|
||||
chat_id,
|
||||
len(messages),
|
||||
len(final_messages),
|
||||
len(summary_record.summary),
|
||||
self.valves.keep_first,
|
||||
self.valves.keep_last,
|
||||
)
|
||||
else:
|
||||
# No summary, use original messages
|
||||
final_messages = messages
|
||||
|
||||
body["messages"] = final_messages
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Inlet] Final send: {len(body['messages'])} messages")
|
||||
print(f"{'='*60}\n")
|
||||
await self._log(
|
||||
f"[Inlet] Final send: {len(body['messages'])} messages\n{'='*60}\n",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
@@ -622,29 +861,50 @@ class Filter:
|
||||
__user__: Optional[dict] = None,
|
||||
__metadata__: dict = None,
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Executed after the LLM response is complete.
|
||||
Calculates Token count in the background and triggers summary generation (does not block current response, does not affect content output).
|
||||
"""
|
||||
chat_id = __metadata__["chat_id"]
|
||||
model = body.get("model", "gpt-3.5-turbo")
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
if not chat_id:
|
||||
await self._log(
|
||||
"[Outlet] ❌ Missing chat_id in metadata, skipping compression",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return body
|
||||
model = body.get("model") or ""
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[Outlet] Chat ID: {chat_id}")
|
||||
print(f"[Outlet] Response complete")
|
||||
# Calculate target compression progress directly
|
||||
# Assuming body['messages'] in outlet contains the full history (including new response)
|
||||
messages = body.get("messages", [])
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
|
||||
if self.valves.debug_mode or self.valves.show_debug_log:
|
||||
await self._log(
|
||||
f"\n{'='*60}\n[Outlet] Chat ID: {chat_id}\n[Outlet] Response complete\n[Outlet] Calculated target compression progress: {target_compressed_count} (Messages: {len(messages)})",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Process Token calculation and summary generation asynchronously in the background (do not wait for completion, do not affect output)
|
||||
asyncio.create_task(
|
||||
self._check_and_generate_summary_async(
|
||||
chat_id, model, body, __user__, __event_emitter__
|
||||
chat_id,
|
||||
model,
|
||||
body,
|
||||
__user__,
|
||||
target_compressed_count,
|
||||
__event_emitter__,
|
||||
__event_call__,
|
||||
)
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Outlet] Background processing started")
|
||||
print(f"{'='*60}\n")
|
||||
await self._log(
|
||||
f"[Outlet] Background processing started\n{'='*60}\n",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
@@ -654,7 +914,9 @@ class Filter:
|
||||
model: str,
|
||||
body: dict,
|
||||
user_data: Optional[dict],
|
||||
target_compressed_count: Optional[int],
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
):
|
||||
"""
|
||||
Background processing: Calculates Token count and generates summary (does not block response).
|
||||
@@ -668,36 +930,58 @@ class Filter:
|
||||
"compression_threshold_tokens", self.valves.compression_threshold_tokens
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n[🔍 Background Calculation] Starting Token count...")
|
||||
await self._log(
|
||||
f"\n[🔍 Background Calculation] Starting Token count...",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Calculate Token count in a background thread
|
||||
current_tokens = await asyncio.to_thread(
|
||||
self._calculate_messages_tokens, messages
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🔍 Background Calculation] Token count: {current_tokens}")
|
||||
await self._log(
|
||||
f"[🔍 Background Calculation] Token count: {current_tokens}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Check if compression is needed
|
||||
if current_tokens >= compression_threshold_tokens:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🔍 Background Calculation] ⚡ Compression threshold triggered (Token: {current_tokens} >= {compression_threshold_tokens})"
|
||||
)
|
||||
await self._log(
|
||||
f"[🔍 Background Calculation] ⚡ Compression threshold triggered (Token: {current_tokens} >= {compression_threshold_tokens})",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Proceed to generate summary
|
||||
await self._generate_summary_async(
|
||||
messages, chat_id, body, user_data, __event_emitter__
|
||||
messages,
|
||||
chat_id,
|
||||
body,
|
||||
user_data,
|
||||
target_compressed_count,
|
||||
__event_emitter__,
|
||||
__event_call__,
|
||||
)
|
||||
else:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🔍 Background Calculation] Compression threshold not reached (Token: {current_tokens} < {compression_threshold_tokens})"
|
||||
)
|
||||
await self._log(
|
||||
f"[🔍 Background Calculation] Compression threshold not reached (Token: {current_tokens} < {compression_threshold_tokens})",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[🔍 Background Calculation] ❌ Error: {str(e)}")
|
||||
await self._log(
|
||||
f"[🔍 Background Calculation] ❌ Error: {str(e)}",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
def _clean_model_id(self, model_id: Optional[str]) -> Optional[str]:
|
||||
"""Cleans the model ID by removing whitespace and quotes."""
|
||||
if not model_id:
|
||||
return None
|
||||
cleaned = model_id.strip().strip('"').strip("'")
|
||||
return cleaned if cleaned else None
|
||||
|
||||
async def _generate_summary_async(
|
||||
self,
|
||||
@@ -705,7 +989,9 @@ class Filter:
|
||||
chat_id: str,
|
||||
body: dict,
|
||||
user_data: Optional[dict],
|
||||
target_compressed_count: Optional[int],
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
):
|
||||
"""
|
||||
Generates summary asynchronously (runs in background, does not block response).
|
||||
@@ -715,18 +1001,19 @@ class Filter:
|
||||
3. Generate summary for the remaining middle messages.
|
||||
"""
|
||||
try:
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n[🤖 Async Summary Task] Starting...")
|
||||
await self._log(
|
||||
f"\n[🤖 Async Summary Task] Starting...", event_call=__event_call__
|
||||
)
|
||||
|
||||
# 1. Get target compression progress
|
||||
# Prioritize getting from temp_state (calculated by inlet). If unavailable (e.g., after restart), assume current is full history.
|
||||
target_compressed_count = self.temp_state.pop(chat_id, None)
|
||||
# If target_compressed_count is not passed (should not happen with new logic), estimate it
|
||||
if target_compressed_count is None:
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] ⚠️ Could not get inlet state, estimating progress using current message count: {target_compressed_count}"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] ⚠️ target_compressed_count is None, estimating: {target_compressed_count}",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 2. Determine the range of messages to compress (Middle)
|
||||
start_index = self.valves.keep_first
|
||||
@@ -736,25 +1023,33 @@ class Filter:
|
||||
|
||||
# Ensure indices are valid
|
||||
if start_index >= end_index:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Middle messages empty (Start: {start_index}, End: {end_index}), skipping"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] Middle messages empty (Start: {start_index}, End: {end_index}), skipping",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
middle_messages = messages[start_index:end_index]
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Middle messages to process: {len(middle_messages)}"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] Middle messages to process: {len(middle_messages)}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 3. Check Token limit and truncate (Max Context Truncation)
|
||||
# [Optimization] Use the summary model's (if any) threshold to decide how many middle messages can be processed
|
||||
# This allows using a long-window model (like gemini-flash) to compress history exceeding the current model's window
|
||||
summary_model_id = self.valves.summary_model or body.get(
|
||||
"model", "gpt-3.5-turbo"
|
||||
)
|
||||
summary_model_id = self._clean_model_id(
|
||||
self.valves.summary_model
|
||||
) or self._clean_model_id(body.get("model"))
|
||||
|
||||
if not summary_model_id:
|
||||
await self._log(
|
||||
"[🤖 Async Summary Task] ⚠️ Summary model does not exist, skipping compression",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
thresholds = self._get_model_thresholds(summary_model_id)
|
||||
# Note: Using the summary model's max context limit here
|
||||
@@ -762,22 +1057,26 @@ class Filter:
|
||||
"max_context_tokens", self.valves.max_context_tokens
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Using max limit for model {summary_model_id}: {max_context_tokens} Tokens"
|
||||
)
|
||||
|
||||
# Calculate current total Tokens (using summary model for counting)
|
||||
total_tokens = await asyncio.to_thread(
|
||||
self._calculate_messages_tokens, messages
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] Using max limit for model {summary_model_id}: {max_context_tokens} Tokens",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
if total_tokens > max_context_tokens:
|
||||
excess_tokens = total_tokens - max_context_tokens
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] ⚠️ Total Tokens ({total_tokens}) exceed summary model limit ({max_context_tokens}), need to remove approx {excess_tokens} Tokens"
|
||||
)
|
||||
# Calculate tokens for middle messages only (plus buffer for prompt)
|
||||
# We only send middle_messages to the summary model, so we shouldn't count the full history against its limit.
|
||||
middle_tokens = await asyncio.to_thread(
|
||||
self._calculate_messages_tokens, middle_messages
|
||||
)
|
||||
# Add buffer for prompt and output (approx 2000 tokens)
|
||||
estimated_input_tokens = middle_tokens + 2000
|
||||
|
||||
if estimated_input_tokens > max_context_tokens:
|
||||
excess_tokens = estimated_input_tokens - max_context_tokens
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] ⚠️ Middle messages ({middle_tokens} Tokens) + Buffer exceed summary model limit ({max_context_tokens}), need to remove approx {excess_tokens} Tokens",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Remove from the head of middle_messages
|
||||
removed_tokens = 0
|
||||
@@ -785,20 +1084,22 @@ class Filter:
|
||||
|
||||
while removed_tokens < excess_tokens and middle_messages:
|
||||
msg_to_remove = middle_messages.pop(0)
|
||||
msg_tokens = self._count_tokens(str(msg_to_remove.get("content", "")))
|
||||
msg_tokens = self._count_tokens(
|
||||
str(msg_to_remove.get("content", ""))
|
||||
)
|
||||
removed_tokens += msg_tokens
|
||||
removed_count += 1
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Removed {removed_count} messages, totaling {removed_tokens} Tokens"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] Removed {removed_count} messages, totaling {removed_tokens} Tokens",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
if not middle_messages:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Middle messages empty after truncation, skipping summary generation"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] Middle messages empty after truncation, skipping summary generation",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
# 4. Build conversation text
|
||||
@@ -820,14 +1121,26 @@ class Filter:
|
||||
)
|
||||
|
||||
new_summary = await self._call_summary_llm(
|
||||
None, conversation_text, body, user_data
|
||||
None,
|
||||
conversation_text,
|
||||
{**body, "model": summary_model_id},
|
||||
user_data,
|
||||
__event_call__,
|
||||
)
|
||||
|
||||
# 6. Save new summary
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
"[Optimization] Saving summary in a background thread to avoid blocking the event loop."
|
||||
if not new_summary:
|
||||
await self._log(
|
||||
"[🤖 Async Summary Task] ⚠️ Summary generation returned empty result, skipping save",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
# 6. Save new summary
|
||||
await self._log(
|
||||
"[Optimization] Saving summary in a background thread to avoid blocking the event loop.",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._save_summary, chat_id, new_summary, target_compressed_count
|
||||
@@ -845,16 +1158,34 @@ class Filter:
|
||||
}
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 Async Summary Task] ✅ Complete! New summary length: {len(new_summary)} characters"
|
||||
)
|
||||
print(
|
||||
f"[🤖 Async Summary Task] Progress update: Compressed up to original message {target_compressed_count}"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] ✅ Complete! New summary length: {len(new_summary)} characters",
|
||||
type="success",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] Progress update: Compressed up to original message {target_compressed_count}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[🤖 Async Summary Task] ❌ Error: {str(e)}")
|
||||
await self._log(
|
||||
f"[🤖 Async Summary Task] ❌ Error: {str(e)}",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"Summary Error: {str(e)[:100]}...",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
@@ -891,12 +1222,15 @@ class Filter:
|
||||
new_conversation_text: str,
|
||||
body: dict,
|
||||
user_data: dict,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Calls the LLM to generate a summary using Open WebUI's built-in method.
|
||||
"""
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] Using Open WebUI's built-in method")
|
||||
await self._log(
|
||||
f"[🤖 LLM Call] Using Open WebUI's built-in method",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Build summary prompt (Optimized)
|
||||
summary_prompt = f"""
|
||||
@@ -933,10 +1267,19 @@ This conversation may contain previous summaries (as system messages or text) an
|
||||
Based on the content above, generate the summary:
|
||||
"""
|
||||
# Determine the model to use
|
||||
model = self.valves.summary_model or body.get("model", "")
|
||||
model = self._clean_model_id(self.valves.summary_model) or self._clean_model_id(
|
||||
body.get("model")
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] Model: {model}")
|
||||
if not model:
|
||||
await self._log(
|
||||
"[🤖 LLM Call] ⚠️ Summary model does not exist, skipping summary generation",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return ""
|
||||
|
||||
await self._log(f"[🤖 LLM Call] Model: {model}", event_call=__event_call__)
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
@@ -954,18 +1297,19 @@ Based on the content above, generate the summary:
|
||||
raise ValueError("Could not get user ID")
|
||||
|
||||
# [Optimization] Get user object in a background thread to avoid blocking the event loop.
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
"[Optimization] Getting user object in a background thread to avoid blocking the event loop."
|
||||
)
|
||||
await self._log(
|
||||
"[Optimization] Getting user object in a background thread to avoid blocking the event loop.",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
user = await asyncio.to_thread(Users.get_user_by_id, user_id)
|
||||
|
||||
if not user:
|
||||
raise ValueError(f"Could not find user: {user_id}")
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] User: {user.email}")
|
||||
print(f"[🤖 LLM Call] Sending request...")
|
||||
await self._log(
|
||||
f"[🤖 LLM Call] User: {user.email}\n[🤖 LLM Call] Sending request...",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Create Request object
|
||||
request = Request(scope={"type": "http", "app": webui_app})
|
||||
@@ -978,20 +1322,31 @@ Based on the content above, generate the summary:
|
||||
|
||||
summary = response["choices"][0]["message"]["content"].strip()
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] ✅ Successfully received summary")
|
||||
await self._log(
|
||||
f"[🤖 LLM Call] ✅ Successfully received summary",
|
||||
type="success",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error occurred while calling LLM ({model}) to generate summary: {str(e)}"
|
||||
error_msg = str(e)
|
||||
# Handle specific error messages
|
||||
if "Model not found" in error_msg:
|
||||
error_message = f"Summary model '{model}' not found."
|
||||
else:
|
||||
error_message = f"Summary LLM Error ({model}): {error_msg}"
|
||||
if not self.valves.summary_model:
|
||||
error_message += (
|
||||
"\n[Hint] You did not specify a summary_model, so the filter attempted to use the current conversation's model. "
|
||||
"If this is a pipeline (Pipe) model or an incompatible model, please specify a compatible summary model (e.g., 'gemini-2.5-flash') in the configuration."
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM Call] ❌ {error_message}")
|
||||
await self._log(
|
||||
f"[🤖 LLM Call] ❌ {error_message}",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
raise Exception(error_message)
|
||||
|
||||
@@ -5,7 +5,7 @@ author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
description: 通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。
|
||||
version: 1.1.0
|
||||
version: 1.1.3
|
||||
openwebui_id: 5c0617cb-a9e4-4bd6-a440-d276534ebd18
|
||||
license: MIT
|
||||
|
||||
@@ -138,6 +138,10 @@ debug_mode (调试模式)
|
||||
默认: true
|
||||
说明: 在日志中打印详细的调试信息。生产环境建议设为 `false`。
|
||||
|
||||
show_debug_log (前端调试日志)
|
||||
默认: false
|
||||
说明: 在浏览器控制台打印调试日志 (F12)。便于前端调试。
|
||||
|
||||
🔧 部署配置
|
||||
═══════════════════════════════════════════════════════
|
||||
|
||||
@@ -286,7 +290,8 @@ class Filter:
|
||||
self.valves = self.Valves()
|
||||
self._db_engine = owui_engine
|
||||
self._SessionLocal = owui_Session
|
||||
self.temp_state = {} # 用于在 inlet 和 outlet 之间传递临时数据
|
||||
self._SessionLocal = owui_Session
|
||||
self._init_database()
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
@@ -345,6 +350,9 @@ class Filter:
|
||||
default=0.1, ge=0.0, le=2.0, description="摘要生成的温度参数"
|
||||
)
|
||||
debug_mode: bool = Field(default=True, description="调试模式,打印详细日志")
|
||||
show_debug_log: bool = Field(
|
||||
default=False, description="在浏览器控制台打印调试日志 (F12)"
|
||||
)
|
||||
|
||||
def _save_summary(self, chat_id: str, summary: str, compressed_count: int):
|
||||
"""保存摘要到数据库"""
|
||||
@@ -426,9 +434,7 @@ class Filter:
|
||||
# 回退策略:粗略估算 (1 token ≈ 4 chars)
|
||||
return len(text) // 4
|
||||
|
||||
def _calculate_messages_tokens(
|
||||
self, messages: List[Dict]
|
||||
) -> int:
|
||||
def _calculate_messages_tokens(self, messages: List[Dict]) -> int:
|
||||
"""计算消息列表的总 Token 数"""
|
||||
total_tokens = 0
|
||||
for msg in messages:
|
||||
@@ -466,41 +472,101 @@ class Filter:
|
||||
"max_context_tokens": self.valves.max_context_tokens,
|
||||
}
|
||||
|
||||
def _inject_summary_to_first_message(self, message: dict, summary: str) -> dict:
|
||||
"""将摘要注入到第一条消息中(追加到内容前面)"""
|
||||
content = message.get("content", "")
|
||||
summary_block = f"【历史对话摘要】\n{summary}\n\n---\n以下是最近的对话:\n\n"
|
||||
async def _emit_debug_log(
|
||||
self,
|
||||
__event_call__,
|
||||
chat_id: str,
|
||||
original_count: int,
|
||||
compressed_count: int,
|
||||
summary_length: int,
|
||||
kept_first: int,
|
||||
kept_last: int,
|
||||
):
|
||||
"""Emit debug log to browser console via JS execution"""
|
||||
if not self.valves.show_debug_log or not __event_call__:
|
||||
return
|
||||
|
||||
# 处理不同内容类型
|
||||
if isinstance(content, list): # 多模态内容
|
||||
# 查找第一个文本部分并在其前面插入摘要
|
||||
new_content = []
|
||||
summary_inserted = False
|
||||
try:
|
||||
# Prepare data for JS
|
||||
log_data = {
|
||||
"chatId": chat_id,
|
||||
"originalCount": original_count,
|
||||
"compressedCount": compressed_count,
|
||||
"summaryLength": summary_length,
|
||||
"keptFirst": kept_first,
|
||||
"keptLast": kept_last,
|
||||
"ratio": (
|
||||
f"{(1 - compressed_count/original_count)*100:.1f}%"
|
||||
if original_count > 0
|
||||
else "0%"
|
||||
),
|
||||
}
|
||||
|
||||
for part in content:
|
||||
if (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and not summary_inserted
|
||||
):
|
||||
# 在第一个文本部分前插入摘要
|
||||
new_content.append(
|
||||
{"type": "text", "text": summary_block + part.get("text", "")}
|
||||
)
|
||||
summary_inserted = True
|
||||
else:
|
||||
new_content.append(part)
|
||||
# Construct JS code
|
||||
js_code = f"""
|
||||
(async function() {{
|
||||
console.group("🗜️ Async Context Compression Debug");
|
||||
console.log("Chat ID:", {json.dumps(chat_id)});
|
||||
console.log("Messages:", {original_count} + " -> " + {compressed_count});
|
||||
console.log("Compression Ratio:", {json.dumps(log_data['ratio'])});
|
||||
console.log("Summary Length:", {summary_length} + " chars");
|
||||
console.log("Configuration:", {{
|
||||
"Keep First": {kept_first},
|
||||
"Keep Last": {kept_last}
|
||||
}});
|
||||
console.groupEnd();
|
||||
}})();
|
||||
"""
|
||||
|
||||
# 如果没有文本部分,在开头插入
|
||||
if not summary_inserted:
|
||||
new_content.insert(0, {"type": "text", "text": summary_block})
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error emitting debug log: {e}")
|
||||
|
||||
message["content"] = new_content
|
||||
async def _log(self, message: str, type: str = "info", event_call=None):
|
||||
"""统一日志输出到后端 (print) 和前端 (console.log)"""
|
||||
# 后端日志
|
||||
if self.valves.debug_mode:
|
||||
print(message)
|
||||
|
||||
elif isinstance(content, str): # 纯文本
|
||||
message["content"] = summary_block + content
|
||||
# 前端日志
|
||||
if self.valves.show_debug_log and event_call:
|
||||
try:
|
||||
css = "color: #3b82f6;" # 默认蓝色
|
||||
if type == "error":
|
||||
css = "color: #ef4444; font-weight: bold;" # 红色
|
||||
elif type == "warning":
|
||||
css = "color: #f59e0b;" # 橙色
|
||||
elif type == "success":
|
||||
css = "color: #10b981; font-weight: bold;" # 绿色
|
||||
|
||||
return message
|
||||
# 清理前端消息:移除分隔符和多余换行
|
||||
lines = message.split("\n")
|
||||
# 保留不以大量等号或连字符开头的行
|
||||
filtered_lines = [
|
||||
line
|
||||
for line in lines
|
||||
if not line.strip().startswith("====")
|
||||
and not line.strip().startswith("----")
|
||||
]
|
||||
clean_message = "\n".join(filtered_lines).strip()
|
||||
|
||||
if not clean_message:
|
||||
return
|
||||
|
||||
# 转义消息中的引号和换行符
|
||||
safe_message = clean_message.replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
js_code = f"""
|
||||
console.log("%c[压缩] {safe_message}", "{css}");
|
||||
"""
|
||||
await event_call({"type": "execute", "data": {"code": js_code}})
|
||||
except Exception as e:
|
||||
print(f"发送前端日志失败: {e}")
|
||||
|
||||
async def inlet(
|
||||
self,
|
||||
@@ -508,6 +574,7 @@ class Filter:
|
||||
__user__: Optional[dict] = None,
|
||||
__metadata__: dict = None,
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
在发送到 LLM 之前执行
|
||||
@@ -516,24 +583,24 @@ class Filter:
|
||||
messages = body.get("messages", [])
|
||||
chat_id = __metadata__["chat_id"]
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[Inlet] Chat ID: {chat_id}")
|
||||
print(f"[Inlet] 收到 {len(messages)} 条消息")
|
||||
if self.valves.debug_mode or self.valves.show_debug_log:
|
||||
await self._log(
|
||||
f"\n{'='*60}\n[Inlet] Chat ID: {chat_id}\n[Inlet] 收到 {len(messages)} 条消息",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 记录原始消息的目标压缩进度,供 outlet 使用
|
||||
# 目标是压缩到倒数第 keep_last 条之前
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
|
||||
# [优化] 简单的状态清理检查
|
||||
if chat_id in self.temp_state:
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Inlet] ⚠️ 覆盖未消费的旧状态 (Chat ID: {chat_id})")
|
||||
# 记录原始消息的目标压缩进度,供 outlet 使用
|
||||
# 目标是压缩到倒数第 keep_last 条之前
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
|
||||
self.temp_state[chat_id] = target_compressed_count
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Inlet] 记录目标压缩进度: {target_compressed_count}")
|
||||
await self._log(
|
||||
f"[Inlet] 记录目标压缩进度: {target_compressed_count}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 加载摘要记录
|
||||
summary_record = await asyncio.to_thread(self._load_summary_record, chat_id)
|
||||
@@ -561,7 +628,7 @@ class Filter:
|
||||
f"---\n"
|
||||
f"以下是最近的对话:"
|
||||
)
|
||||
summary_msg = {"role": "user", "content": summary_content}
|
||||
summary_msg = {"role": "assistant", "content": summary_content}
|
||||
|
||||
# 3. 尾部消息 (Tail) - 从上次压缩点开始的所有消息
|
||||
# 注意:这里必须确保不重复包含头部消息
|
||||
@@ -582,19 +649,32 @@ class Filter:
|
||||
}
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[Inlet] 应用摘要: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})"
|
||||
)
|
||||
await self._log(
|
||||
f"[Inlet] 应用摘要: Head({len(head_messages)}) + Summary + Tail({len(tail_messages)})",
|
||||
type="success",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# Emit debug log to frontend (Keep the structured log as well)
|
||||
await self._emit_debug_log(
|
||||
__event_call__,
|
||||
chat_id,
|
||||
len(messages),
|
||||
len(final_messages),
|
||||
len(summary_record.summary),
|
||||
self.valves.keep_first,
|
||||
self.valves.keep_last,
|
||||
)
|
||||
else:
|
||||
# 没有摘要,使用原始消息
|
||||
final_messages = messages
|
||||
|
||||
body["messages"] = final_messages
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Inlet] 最终发送: {len(body['messages'])} 条消息")
|
||||
print(f"{'='*60}\n")
|
||||
await self._log(
|
||||
f"[Inlet] 最终发送: {len(body['messages'])} 条消息\n{'='*60}\n",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
@@ -604,29 +684,43 @@ class Filter:
|
||||
__user__: Optional[dict] = None,
|
||||
__metadata__: dict = None,
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
在 LLM 响应完成后执行
|
||||
在后台计算 Token 数并触发摘要生成(不阻塞当前响应,不影响内容输出)
|
||||
"""
|
||||
chat_id = __metadata__["chat_id"]
|
||||
model = body.get("model", "gpt-3.5-turbo")
|
||||
model = body.get("model") or ""
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[Outlet] Chat ID: {chat_id}")
|
||||
print(f"[Outlet] 响应完成")
|
||||
# 直接计算目标压缩进度
|
||||
# 假设 outlet 中的 body['messages'] 包含完整历史(包括新响应)
|
||||
messages = body.get("messages", [])
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
|
||||
if self.valves.debug_mode or self.valves.show_debug_log:
|
||||
await self._log(
|
||||
f"\n{'='*60}\n[Outlet] Chat ID: {chat_id}\n[Outlet] 响应完成\n[Outlet] 计算目标压缩进度: {target_compressed_count} (消息数: {len(messages)})",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 在后台异步处理 Token 计算和摘要生成(不等待完成,不影响输出)
|
||||
asyncio.create_task(
|
||||
self._check_and_generate_summary_async(
|
||||
chat_id, model, body, __user__, __event_emitter__
|
||||
chat_id,
|
||||
model,
|
||||
body,
|
||||
__user__,
|
||||
target_compressed_count,
|
||||
__event_emitter__,
|
||||
__event_call__,
|
||||
)
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[Outlet] 后台处理已启动")
|
||||
print(f"{'='*60}\n")
|
||||
await self._log(
|
||||
f"[Outlet] 后台处理已启动\n{'='*60}\n",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
return body
|
||||
|
||||
@@ -636,7 +730,9 @@ class Filter:
|
||||
model: str,
|
||||
body: dict,
|
||||
user_data: Optional[dict],
|
||||
target_compressed_count: Optional[int],
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
):
|
||||
"""
|
||||
后台处理:计算 Token 数并生成摘要(不阻塞响应)
|
||||
@@ -650,36 +746,58 @@ class Filter:
|
||||
"compression_threshold_tokens", self.valves.compression_threshold_tokens
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n[🔍 后台计算] 开始 Token 计数...")
|
||||
await self._log(
|
||||
f"\n[🔍 后台计算] 开始 Token 计数...",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 在后台线程中计算 Token 数
|
||||
current_tokens = await asyncio.to_thread(
|
||||
self._calculate_messages_tokens, messages
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🔍 后台计算] Token 数: {current_tokens}")
|
||||
await self._log(
|
||||
f"[🔍 后台计算] Token 数: {current_tokens}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 检查是否需要压缩
|
||||
if current_tokens >= compression_threshold_tokens:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🔍 后台计算] ⚡ 触发压缩阈值 (Token: {current_tokens} >= {compression_threshold_tokens})"
|
||||
)
|
||||
await self._log(
|
||||
f"[🔍 后台计算] ⚡ 触发压缩阈值 (Token: {current_tokens} >= {compression_threshold_tokens})",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 继续生成摘要
|
||||
await self._generate_summary_async(
|
||||
messages, chat_id, body, user_data, __event_emitter__
|
||||
messages,
|
||||
chat_id,
|
||||
body,
|
||||
user_data,
|
||||
target_compressed_count,
|
||||
__event_emitter__,
|
||||
__event_call__,
|
||||
)
|
||||
else:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🔍 后台计算] 未触发压缩阈值 (Token: {current_tokens} < {compression_threshold_tokens})"
|
||||
)
|
||||
await self._log(
|
||||
f"[🔍 后台计算] 未触发压缩阈值 (Token: {current_tokens} < {compression_threshold_tokens})",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[🔍 后台计算] ❌ 错误: {str(e)}")
|
||||
await self._log(
|
||||
f"[🔍 后台计算] ❌ 错误: {str(e)}",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
def _clean_model_id(self, model_id: Optional[str]) -> Optional[str]:
|
||||
"""Cleans the model ID by removing whitespace and quotes."""
|
||||
if not model_id:
|
||||
return None
|
||||
cleaned = model_id.strip().strip('"').strip("'")
|
||||
return cleaned if cleaned else None
|
||||
|
||||
async def _generate_summary_async(
|
||||
self,
|
||||
@@ -687,7 +805,9 @@ class Filter:
|
||||
chat_id: str,
|
||||
body: dict,
|
||||
user_data: Optional[dict],
|
||||
target_compressed_count: Optional[int],
|
||||
__event_emitter__: Callable[[Any], Awaitable[None]] = None,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
):
|
||||
"""
|
||||
异步生成摘要(后台执行,不阻塞响应)
|
||||
@@ -697,18 +817,17 @@ class Filter:
|
||||
3. 对剩余的中间消息生成摘要。
|
||||
"""
|
||||
try:
|
||||
if self.valves.debug_mode:
|
||||
print(f"\n[🤖 异步摘要任务] 开始...")
|
||||
await self._log(f"\n[🤖 异步摘要任务] 开始...", event_call=__event_call__)
|
||||
|
||||
# 1. 获取目标压缩进度
|
||||
# 优先从 temp_state 获取(由 inlet 计算),如果获取不到(例如重启后),则假设当前是完整历史
|
||||
target_compressed_count = self.temp_state.pop(chat_id, None)
|
||||
# 如果未传递 target_compressed_count(新逻辑下不应发生),则进行估算
|
||||
if target_compressed_count is None:
|
||||
target_compressed_count = max(0, len(messages) - self.valves.keep_last)
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 异步摘要任务] ⚠️ 无法获取 inlet 状态,使用当前消息数估算进度: {target_compressed_count}"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] ⚠️ target_compressed_count 为 None,进行估算: {target_compressed_count}",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 2. 确定待压缩的消息范围 (Middle)
|
||||
start_index = self.valves.keep_first
|
||||
@@ -718,21 +837,33 @@ class Filter:
|
||||
|
||||
# 确保索引有效
|
||||
if start_index >= end_index:
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 异步摘要任务] 中间消息为空 (Start: {start_index}, End: {end_index}),跳过"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] 中间消息为空 (Start: {start_index}, End: {end_index}),跳过",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
middle_messages = messages[start_index:end_index]
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 异步摘要任务] 待处理中间消息: {len(middle_messages)} 条")
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] 待处理中间消息: {len(middle_messages)} 条",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 3. 检查 Token 上限并截断 (Max Context Truncation)
|
||||
# [优化] 使用摘要模型(如果有)的阈值来决定能处理多少中间消息
|
||||
# 这样可以用长窗口模型(如 gemini-flash)来压缩超过当前模型窗口的历史记录
|
||||
summary_model_id = self.valves.summary_model or body.get("model")
|
||||
summary_model_id = self._clean_model_id(
|
||||
self.valves.summary_model
|
||||
) or self._clean_model_id(body.get("model"))
|
||||
|
||||
if not summary_model_id:
|
||||
await self._log(
|
||||
"[🤖 异步摘要任务] ⚠️ 摘要模型不存在,跳过压缩",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
thresholds = self._get_model_thresholds(summary_model_id)
|
||||
# 注意:这里使用的是摘要模型的最大上下文限制
|
||||
@@ -740,22 +871,26 @@ class Filter:
|
||||
"max_context_tokens", self.valves.max_context_tokens
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 异步摘要任务] 使用模型 {summary_model_id} 的上限: {max_context_tokens} Tokens"
|
||||
)
|
||||
|
||||
# 计算当前总 Token (使用摘要模型进行计数)
|
||||
total_tokens = await asyncio.to_thread(
|
||||
self._calculate_messages_tokens, messages
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] 使用模型 {summary_model_id} 的上限: {max_context_tokens} Tokens",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
if total_tokens > max_context_tokens:
|
||||
excess_tokens = total_tokens - max_context_tokens
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 异步摘要任务] ⚠️ 总 Token ({total_tokens}) 超过摘要模型上限 ({max_context_tokens}),需要移除约 {excess_tokens} Token"
|
||||
)
|
||||
# 计算中间消息的 Token (加上提示词的缓冲)
|
||||
# 我们只把 middle_messages 发送给摘要模型,所以不应该把完整历史计入限制
|
||||
middle_tokens = await asyncio.to_thread(
|
||||
self._calculate_messages_tokens, middle_messages
|
||||
)
|
||||
# 增加提示词和输出的缓冲 (约 2000 Tokens)
|
||||
estimated_input_tokens = middle_tokens + 2000
|
||||
|
||||
if estimated_input_tokens > max_context_tokens:
|
||||
excess_tokens = estimated_input_tokens - max_context_tokens
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] ⚠️ 中间消息 ({middle_tokens} Tokens) + 缓冲超过摘要模型上限 ({max_context_tokens}),需要移除约 {excess_tokens} Token",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 从 middle_messages 头部开始移除
|
||||
removed_tokens = 0
|
||||
@@ -769,14 +904,16 @@ class Filter:
|
||||
removed_tokens += msg_tokens
|
||||
removed_count += 1
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(
|
||||
f"[🤖 异步摘要任务] 已移除 {removed_count} 条消息,共 {removed_tokens} Token"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] 已移除 {removed_count} 条消息,共 {removed_tokens} Token",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
if not middle_messages:
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 异步摘要任务] 截断后中间消息为空,跳过摘要生成")
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] 截断后中间消息为空,跳过摘要生成",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
# 4. 构建对话文本
|
||||
@@ -798,12 +935,26 @@ class Filter:
|
||||
)
|
||||
|
||||
new_summary = await self._call_summary_llm(
|
||||
None, conversation_text, body, user_data
|
||||
None,
|
||||
conversation_text,
|
||||
{**body, "model": summary_model_id},
|
||||
user_data,
|
||||
__event_call__,
|
||||
)
|
||||
|
||||
if not new_summary:
|
||||
await self._log(
|
||||
"[🤖 异步摘要任务] ⚠️ 摘要生成返回空结果,跳过保存",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return
|
||||
|
||||
# 6. 保存新摘要
|
||||
if self.valves.debug_mode:
|
||||
print("[优化] 正在后台线程中保存摘要,以避免阻塞事件循环。")
|
||||
await self._log(
|
||||
"[优化] 在后台线程中保存摘要以避免阻塞事件循环。",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._save_summary, chat_id, new_summary, target_compressed_count
|
||||
@@ -815,32 +966,52 @@ class Filter:
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"上下文摘要已更新 (已压缩 {len(middle_messages)} 条消息)",
|
||||
"description": f"上下文摘要已更新 (压缩了 {len(middle_messages)} 条消息)",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 异步摘要任务] ✅ 完成!新摘要长度: {len(new_summary)} 字符")
|
||||
print(
|
||||
f"[🤖 异步摘要任务] 进度更新: 已压缩至原始第 {target_compressed_count} 条消息"
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] ✅ 完成!新摘要长度: {len(new_summary)} 字符",
|
||||
type="success",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] 进度更新: 已压缩至原始消息 {target_compressed_count}",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[🤖 异步摘要任务] ❌ 错误: {str(e)}")
|
||||
await self._log(
|
||||
f"[🤖 异步摘要任务] ❌ 错误: {str(e)}",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"摘要生成错误: {str(e)[:100]}...",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
def _format_messages_for_summary(self, messages: list) -> str:
|
||||
"""格式化消息用于摘要"""
|
||||
"""Formats messages for summarization."""
|
||||
formatted = []
|
||||
for i, msg in enumerate(messages, 1):
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
|
||||
# 处理多模态内容
|
||||
# Handle multimodal content
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for part in content:
|
||||
@@ -848,10 +1019,10 @@ class Filter:
|
||||
text_parts.append(part.get("text", ""))
|
||||
content = " ".join(text_parts)
|
||||
|
||||
# 处理角色名称
|
||||
role_name = {"user": "用户", "assistant": "助手"}.get(role, role)
|
||||
# Handle role name
|
||||
role_name = {"user": "User", "assistant": "Assistant"}.get(role, role)
|
||||
|
||||
# 限制每条消息的长度,避免过长
|
||||
# Limit length of each message to avoid excessive length
|
||||
if len(content) > 500:
|
||||
content = content[:500] + "..."
|
||||
|
||||
@@ -865,12 +1036,15 @@ class Filter:
|
||||
new_conversation_text: str,
|
||||
body: dict,
|
||||
user_data: dict,
|
||||
__event_call__: Callable[[Any], Awaitable[None]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
使用 Open WebUI 内置方法调用 LLM 生成摘要
|
||||
调用 LLM 生成摘要,使用 Open Web UI 的内置方法。
|
||||
"""
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM 调用] 使用 Open WebUI 内置方法")
|
||||
await self._log(
|
||||
f"[🤖 LLM 调用] 使用 Open Web UI 内置方法",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 构建摘要提示词 (优化版)
|
||||
summary_prompt = f"""
|
||||
@@ -907,10 +1081,19 @@ class Filter:
|
||||
请根据上述内容,生成摘要:
|
||||
"""
|
||||
# 确定使用的模型
|
||||
model = self.valves.summary_model or body.get("model", "")
|
||||
model = self._clean_model_id(self.valves.summary_model) or self._clean_model_id(
|
||||
body.get("model")
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM 调用] 模型: {model}")
|
||||
if not model:
|
||||
await self._log(
|
||||
"[🤖 LLM 调用] ⚠️ 摘要模型不存在,跳过摘要生成",
|
||||
type="warning",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
return ""
|
||||
|
||||
await self._log(f"[🤖 LLM 调用] 模型: {model}", event_call=__event_call__)
|
||||
|
||||
# 构建 payload
|
||||
payload = {
|
||||
@@ -927,17 +1110,20 @@ class Filter:
|
||||
if not user_id:
|
||||
raise ValueError("无法获取用户 ID")
|
||||
|
||||
# [优化] 在后台线程中获取用户对象,以避免阻塞事件循环
|
||||
if self.valves.debug_mode:
|
||||
print("[优化] 正在后台线程中获取用户对象,以避免阻塞事件循环。")
|
||||
# [优化] 在后台线程中获取用户对象以避免阻塞事件循环
|
||||
await self._log(
|
||||
"[优化] 在后台线程中获取用户对象以避免阻塞事件循环。",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
user = await asyncio.to_thread(Users.get_user_by_id, user_id)
|
||||
|
||||
if not user:
|
||||
raise ValueError(f"无法找到用户: {user_id}")
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM 调用] 用户: {user.email}")
|
||||
print(f"[🤖 LLM 调用] 发送请求...")
|
||||
await self._log(
|
||||
f"[🤖 LLM 调用] 用户: {user.email}\n[🤖 LLM 调用] 发送请求...",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
# 创建 Request 对象
|
||||
request = Request(scope={"type": "http", "app": webui_app})
|
||||
@@ -950,20 +1136,31 @@ class Filter:
|
||||
|
||||
summary = response["choices"][0]["message"]["content"].strip()
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM 调用] ✅ 成功获取摘要")
|
||||
await self._log(
|
||||
f"[🤖 LLM 调用] ✅ 成功接收摘要",
|
||||
type="success",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"调用 LLM ({model}) 生成摘要时发生错误: {str(e)}"
|
||||
error_msg = str(e)
|
||||
# Handle specific error messages
|
||||
if "Model not found" in error_msg:
|
||||
error_message = f"摘要模型 '{model}' 不存在。"
|
||||
else:
|
||||
error_message = f"摘要 LLM 错误 ({model}): {error_msg}"
|
||||
if not self.valves.summary_model:
|
||||
error_message += (
|
||||
"\n[提示] 您没有指定摘要模型 (summary_model),因此尝试使用当前对话的模型。"
|
||||
"如果这是一个流水线(Pipe)模型或不兼容的模型,请在配置中指定一个兼容的摘要模型(如 'gemini-2.5-flash')。"
|
||||
"\n[提示] 您未指定 summary_model,因此过滤器尝试使用当前对话的模型。"
|
||||
"如果这是流水线 (Pipe) 模型或不兼容的模型,请在配置中指定兼容的摘要模型 (例如 'gemini-2.5-flash')。"
|
||||
)
|
||||
|
||||
if self.valves.debug_mode:
|
||||
print(f"[🤖 LLM 调用] ❌ {error_message}")
|
||||
await self._log(
|
||||
f"[🤖 LLM 调用] ❌ {error_message}",
|
||||
type="error",
|
||||
event_call=__event_call__,
|
||||
)
|
||||
|
||||
raise Exception(error_message)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""
|
||||
title: Context & Model Enhancement Filter
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.2
|
||||
version: 0.3
|
||||
|
||||
description:
|
||||
一个功能全面的 Filter 插件,用于增强请求上下文和优化模型功能。提供四大核心功能:
|
||||
一个专注于增强请求上下文和优化模型功能的 Filter 插件。提供三大核心功能:
|
||||
|
||||
1. 环境变量注入:在每条用户消息前自动注入用户环境变量(用户名、时间、时区、语言等)
|
||||
- 支持纯文本、图片、多模态消息
|
||||
@@ -24,222 +21,24 @@ description:
|
||||
- 动态模型重定向
|
||||
- 智能化的模型识别和适配
|
||||
|
||||
4. 智能内容规范化:生产级的内容清洗与修复系统
|
||||
- 智能修复损坏的代码块(前缀、后缀、缩进)
|
||||
- 规范化 LaTeX 公式格式(行内/块级)
|
||||
- 优化思维链标签(</thought>)格式
|
||||
- 自动闭合未结束的代码块
|
||||
- 智能列表格式修复
|
||||
- 清理冗余的 XML 标签
|
||||
- 可配置的规则系统
|
||||
|
||||
features:
|
||||
- 自动化环境变量管理
|
||||
- 智能模型功能适配
|
||||
- 异步状态反馈
|
||||
- 幂等性保证
|
||||
- 多模型支持
|
||||
- 智能内容清洗与规范化
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Callable
|
||||
from typing import Optional
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class NormalizerConfig:
|
||||
"""规范化配置类,用于动态启用/禁用特定规则"""
|
||||
enable_escape_fix: bool = True # 修复转义字符
|
||||
enable_thought_tag_fix: bool = True # 修复思考链标签
|
||||
enable_code_block_fix: bool = True # 修复代码块格式
|
||||
enable_latex_fix: bool = True # 修复 LaTeX 公式格式
|
||||
enable_list_fix: bool = False # 修复列表换行
|
||||
enable_unclosed_block_fix: bool = True # 修复未闭合代码块
|
||||
enable_fullwidth_symbol_fix: bool = False # 修复代码内的全角符号
|
||||
enable_xml_tag_cleanup: bool = True # 清理 XML 残留标签
|
||||
|
||||
# 自定义清理函数列表(高级扩展用)
|
||||
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
|
||||
|
||||
class ContentNormalizer:
|
||||
"""LLM 输出内容规范化器 - 生产级实现"""
|
||||
|
||||
# --- 1. 预编译正则表达式(性能优化) ---
|
||||
_PATTERNS = {
|
||||
# 代码块前缀:如果 ``` 前面不是行首也不是换行符
|
||||
'code_block_prefix': re.compile(r'(?<!^)(?<!\n)(```)', re.MULTILINE),
|
||||
|
||||
# 代码块后缀:匹配 ```语言名 后面紧跟非空白字符(没有换行)
|
||||
# 匹配 ```python code 这种情况,但不匹配 ```python 或 ```python\n
|
||||
'code_block_suffix': re.compile(r'(```[\w\+\-\.]*)[ \t]+([^\n\r])'),
|
||||
|
||||
# 代码块缩进:行首的空白字符 + ```
|
||||
'code_block_indent': re.compile(r'^[ \t]+(```)', re.MULTILINE),
|
||||
|
||||
# 思考链标签:</thought> 后可能跟空格或换行
|
||||
'thought_tag': re.compile(r'</thought>[ \t]*\n*'),
|
||||
|
||||
# LaTeX 块级公式:\[ ... \]
|
||||
'latex_bracket_block': re.compile(r'\\\[(.+?)\\\]', re.DOTALL),
|
||||
# LaTeX 行内公式:\( ... \)
|
||||
'latex_paren_inline': re.compile(r'\\\((.+?)\\\)'),
|
||||
|
||||
# 列表项:非换行符 + 数字 + 点 + 空格 (e.g. "Text1. Item")
|
||||
'list_item': re.compile(r'([^\n])(\d+\. )'),
|
||||
|
||||
# XML 残留标签 (如 Claude 的 artifacts)
|
||||
'xml_artifacts': re.compile(r'</?(?:antArtifact|antThinking|artifact)[^>]*>', re.IGNORECASE),
|
||||
}
|
||||
|
||||
def __init__(self, config: Optional[NormalizerConfig] = None):
|
||||
self.config = config or NormalizerConfig()
|
||||
self.applied_fixes = []
|
||||
|
||||
def normalize(self, content: str) -> str:
|
||||
"""主入口:按顺序应用所有规范化规则"""
|
||||
self.applied_fixes = []
|
||||
if not content:
|
||||
return content
|
||||
|
||||
try:
|
||||
# 1. 转义字符修复(必须最先执行,否则影响后续正则)
|
||||
if self.config.enable_escape_fix:
|
||||
original = content
|
||||
content = self._fix_escape_characters(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("修复转义字符")
|
||||
|
||||
# 2. 思考链标签规范化
|
||||
if self.config.enable_thought_tag_fix:
|
||||
original = content
|
||||
content = self._fix_thought_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("规范化思考链")
|
||||
|
||||
# 3. 代码块格式修复
|
||||
if self.config.enable_code_block_fix:
|
||||
original = content
|
||||
content = self._fix_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("修复代码块格式")
|
||||
|
||||
# 4. LaTeX 公式规范化
|
||||
if self.config.enable_latex_fix:
|
||||
original = content
|
||||
content = self._fix_latex_formulas(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("规范化 LaTeX 公式")
|
||||
|
||||
# 5. 列表格式修复
|
||||
if self.config.enable_list_fix:
|
||||
original = content
|
||||
content = self._fix_list_formatting(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("修复列表格式")
|
||||
|
||||
# 6. 未闭合代码块检测与修复
|
||||
if self.config.enable_unclosed_block_fix:
|
||||
original = content
|
||||
content = self._fix_unclosed_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("闭合未结束代码块")
|
||||
|
||||
# 7. 全角符号转半角(仅代码块内)
|
||||
if self.config.enable_fullwidth_symbol_fix:
|
||||
original = content
|
||||
content = self._fix_fullwidth_symbols_in_code(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("全角符号转半角")
|
||||
|
||||
# 8. XML 标签残留清理
|
||||
if self.config.enable_xml_tag_cleanup:
|
||||
original = content
|
||||
content = self._cleanup_xml_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("清理 XML 标签")
|
||||
|
||||
# 9. 执行自定义清理函数
|
||||
for cleaner in self.config.custom_cleaners:
|
||||
original = content
|
||||
content = cleaner(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("执行自定义清理")
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
# 生产环境保底机制:如果清洗过程报错,返回原始内容,避免阻断服务
|
||||
logger.error(f"内容规范化失败: {e}", exc_info=True)
|
||||
return content
|
||||
|
||||
def _fix_escape_characters(self, content: str) -> str:
|
||||
"""修复过度转义的字符"""
|
||||
# 注意:先处理具体的转义序列,再处理通用的双反斜杠
|
||||
content = content.replace("\\r\\n", "\n")
|
||||
content = content.replace("\\n", "\n")
|
||||
content = content.replace("\\t", "\t")
|
||||
# 修复过度转义的反斜杠 (例如路径 C:\\Users)
|
||||
content = content.replace("\\\\", "\\")
|
||||
return content
|
||||
|
||||
def _fix_thought_tags(self, content: str) -> str:
|
||||
"""规范化 </thought> 标签,统一为空两行"""
|
||||
return self._PATTERNS['thought_tag'].sub("</thought>\n\n", content)
|
||||
|
||||
def _fix_code_blocks(self, content: str) -> str:
|
||||
"""修复代码块格式(独占行、换行、去缩进)"""
|
||||
# C: 移除代码块前的缩进(必须先执行,否则影响下面的判断)
|
||||
content = self._PATTERNS['code_block_indent'].sub(r"\1", content)
|
||||
# A: 确保 ``` 前有换行
|
||||
content = self._PATTERNS['code_block_prefix'].sub(r"\n\1", content)
|
||||
# B: 确保 ```语言标识 后有换行
|
||||
content = self._PATTERNS['code_block_suffix'].sub(r"\1\n\2", content)
|
||||
return content
|
||||
|
||||
def _fix_latex_formulas(self, content: str) -> str:
|
||||
"""规范化 LaTeX 公式:\[ -> $$ (块级), \( -> $ (行内)"""
|
||||
content = self._PATTERNS['latex_bracket_block'].sub(r"$$\1$$", content)
|
||||
content = self._PATTERNS['latex_paren_inline'].sub(r"$\1$", content)
|
||||
return content
|
||||
|
||||
def _fix_list_formatting(self, content: str) -> str:
|
||||
"""修复列表项缺少换行的问题 (如 'text1. item' -> 'text\\n1. item')"""
|
||||
return self._PATTERNS['list_item'].sub(r"\1\n\2", content)
|
||||
|
||||
def _fix_unclosed_code_blocks(self, content: str) -> str:
|
||||
"""检测并修复未闭合的代码块"""
|
||||
if content.count("```") % 2 != 0:
|
||||
logger.warning("检测到未闭合的代码块,自动补全")
|
||||
content += "\n```"
|
||||
return content
|
||||
|
||||
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
|
||||
"""在代码块内将全角符号转为半角(精细化操作)"""
|
||||
# 常见误用的全角符号映射
|
||||
FULLWIDTH_MAP = {
|
||||
',': ',', '。': '.', '(': '(', ')': ')',
|
||||
'【': '[', '】': ']', ';': ';', ':': ':',
|
||||
'?': '?', '!': '!', '"': '"', '"': '"',
|
||||
''': "'", ''': "'",
|
||||
}
|
||||
|
||||
parts = content.split("```")
|
||||
# 代码块内容位于索引 1, 3, 5... (奇数位)
|
||||
for i in range(1, len(parts), 2):
|
||||
for full, half in FULLWIDTH_MAP.items():
|
||||
parts[i] = parts[i].replace(full, half)
|
||||
|
||||
return "```".join(parts)
|
||||
|
||||
def _cleanup_xml_tags(self, content: str) -> str:
|
||||
"""移除无关的 XML 标签"""
|
||||
return self._PATTERNS['xml_artifacts'].sub("", content)
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
@@ -349,13 +148,9 @@ class Filter:
|
||||
body["model"] = body["model"] + "-search"
|
||||
features["web_search"] = False
|
||||
search_enabled_for_model = True
|
||||
if user_email == "yi204o@qq.com":
|
||||
features["web_search"] = False
|
||||
|
||||
# 如果启用了模型本身的搜索能力,发送状态提示
|
||||
if search_enabled_for_model and __event_emitter__:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.create_task(
|
||||
self._emit_search_status(__event_emitter__, model_name)
|
||||
@@ -464,8 +259,6 @@ class Filter:
|
||||
|
||||
# 环境变量注入成功后,发送状态提示给用户
|
||||
if env_injected and __event_emitter__:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
# 如果在异步环境中,使用 await
|
||||
asyncio.create_task(self._emit_env_status(__event_emitter__))
|
||||
@@ -506,67 +299,3 @@ class Filter:
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"发送搜索状态提示时出错: {e}")
|
||||
|
||||
async def _emit_normalization_status(self, __event_emitter__, applied_fixes: List[str] = None):
|
||||
"""
|
||||
发送内容规范化完成的状态提示
|
||||
"""
|
||||
description = "✓ 内容已自动规范化"
|
||||
if applied_fixes:
|
||||
description += f":{', '.join(applied_fixes)}"
|
||||
|
||||
try:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": description,
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"发送规范化状态提示时出错: {e}")
|
||||
|
||||
def _contains_html(self, content: str) -> bool:
|
||||
"""
|
||||
检测内容是否包含 HTML 标签
|
||||
"""
|
||||
# 匹配常见的 HTML 标签
|
||||
pattern = r"<\s*/?\s*(?:html|head|body|div|span|p|br|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|b|i|strong|em|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
|
||||
return bool(re.search(pattern, content, re.IGNORECASE))
|
||||
|
||||
def outlet(self, body: dict, __user__: Optional[dict] = None, __event_emitter__=None) -> dict:
|
||||
"""
|
||||
处理传出响应体,通过修改最后一条助手消息的内容。
|
||||
使用 ContentNormalizer 进行全面的内容规范化。
|
||||
"""
|
||||
if "messages" in body and body["messages"]:
|
||||
last = body["messages"][-1]
|
||||
content = last.get("content", "") or ""
|
||||
|
||||
if last.get("role") == "assistant" and isinstance(content, str):
|
||||
# 如果包含 HTML,跳过规范化,为了防止错误格式化
|
||||
if self._contains_html(content):
|
||||
return body
|
||||
|
||||
# 初始化规范化器
|
||||
normalizer = ContentNormalizer()
|
||||
|
||||
# 执行规范化
|
||||
new_content = normalizer.normalize(content)
|
||||
|
||||
# 更新内容
|
||||
if new_content != content:
|
||||
last["content"] = new_content
|
||||
# 如果内容发生了改变,发送状态提示
|
||||
if __event_emitter__:
|
||||
import asyncio
|
||||
try:
|
||||
# 传入 applied_fixes
|
||||
asyncio.create_task(self._emit_normalization_status(__event_emitter__, normalizer.applied_fixes))
|
||||
except RuntimeError:
|
||||
# 假如不在循环中,则忽略
|
||||
pass
|
||||
|
||||
return body
|
||||
|
||||
188
plugins/filters/markdown_normalizer/FEATURES_CN.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Markdown Normalizer 功能详解
|
||||
|
||||
本插件旨在修复 LLM 输出中常见的 Markdown 格式问题,确保在 Open WebUI 中完美渲染。以下是支持的修复功能列表及示例。
|
||||
|
||||
## 1. 代码块修复 (Code Block Fixes)
|
||||
|
||||
### 1.1 去除代码块缩进
|
||||
LLM 有时会在代码块前添加空格缩进,导致渲染失效。本插件会自动移除这些缩进。
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
|
||||
### 1.2 补全代码块前后换行
|
||||
代码块标记 ` ``` ` 必须独占一行。如果 LLM 将其与文本混在一行,插件会自动修复。
|
||||
|
||||
**Before:**
|
||||
Here is code:```python
|
||||
print("hello")```
|
||||
|
||||
**After:**
|
||||
Here is code:
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
|
||||
### 1.3 修复语言标识符后的换行
|
||||
有时 LLM 会忘记在语言标识符(如 `python`)后换行。
|
||||
|
||||
**Before:**
|
||||
```python print("hello")
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
|
||||
### 1.4 自动闭合代码块
|
||||
如果输出被截断或 LLM 忘记闭合代码块,插件会自动添加结尾的 ` ``` `。
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
print("unfinished code...")
|
||||
|
||||
**After:**
|
||||
```python
|
||||
print("unfinished code...")
|
||||
```
|
||||
|
||||
## 2. LaTeX 公式规范化 (LaTeX Normalization)
|
||||
|
||||
Open WebUI 使用 MathJax/KaTeX 渲染公式,通常需要 `$$` 或 `$` 包裹。本插件会将常见的 LaTeX 括号语法转换为标准格式。
|
||||
|
||||
**Before:**
|
||||
块级公式:\[ E = mc^2 \]
|
||||
行内公式:\( a^2 + b^2 = c^2 \)
|
||||
|
||||
**After:**
|
||||
块级公式:$$ E = mc^2 $$
|
||||
行内公式:$ a^2 + b^2 = c^2 $
|
||||
|
||||
## 3. 转义字符清理 (Escape Character Fix)
|
||||
|
||||
修复过度转义的字符,这常见于某些 API 返回的原始字符串中。
|
||||
|
||||
**Before:**
|
||||
Line 1\\nLine 2\\tTabbed
|
||||
|
||||
**After:**
|
||||
Line 1
|
||||
Line 2 Tabbed
|
||||
|
||||
## 4. 思维链标签规范化 (Thought Tag Fix)
|
||||
**功能**:
|
||||
1. 确保 `</thought>` 标签后有足够的空行,防止思维链内容与正文粘连。
|
||||
2. **标准化标签**: 将 `<think>` (DeepSeek 等模型常用) 或 `<thinking>` 统一转换为 Open WebUI 标准的 `<thought>` 标签,以便正确触发 UI 的折叠功能。
|
||||
|
||||
**默认**: 开启 (`enable_thought_tag_fix = True`)
|
||||
|
||||
**示例**:
|
||||
* **Before**: `<think>Thinking...</think>Response starts here.`
|
||||
* **After**:
|
||||
```xml
|
||||
<thought>Thinking...</thought>
|
||||
|
||||
Response starts here.
|
||||
```
|
||||
|
||||
## 5. 列表格式修复 (List Formatting Fix)
|
||||
|
||||
*默认关闭,需在设置中开启*
|
||||
|
||||
修复列表项缺少换行的问题。
|
||||
|
||||
**Before:**
|
||||
Header1. Item 1
|
||||
|
||||
**After:**
|
||||
Header
|
||||
1. Item 1
|
||||
|
||||
## 6. 全角符号转半角 (Full-width Symbol Fix)
|
||||
|
||||
*默认关闭,需在设置中开启*
|
||||
|
||||
仅在**代码块内部**将全角符号转换为半角符号,防止代码因符号问题无法运行。
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
if x == 1:
|
||||
print("hello")
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
if x == 1:
|
||||
print("hello")
|
||||
```
|
||||
|
||||
## 7. Mermaid 语法修复 (Mermaid Syntax Fix)
|
||||
**功能**: 修复 Mermaid 图表中常见的语法错误,特别是未加引号的标签包含特殊字符、嵌套括号或 HTML 标签的情况。
|
||||
**默认**: 开启 (`enable_mermaid_fix = True`)
|
||||
|
||||
### 7.1 基础特殊字符
|
||||
**Before**:
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Label with (parens)] --> B(Label with [brackets])
|
||||
```
|
||||
**After**:
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Label with (parens)"] --> B("Label with [brackets]")
|
||||
```
|
||||
|
||||
### 7.2 嵌套括号修复 (v1.1.0+)
|
||||
**Before**:
|
||||
```mermaid
|
||||
graph TD
|
||||
A((开始: 发现可疑快照)) --> B[物理损坏(Allocation Errors)]
|
||||
```
|
||||
**After**:
|
||||
```mermaid
|
||||
graph TD
|
||||
A(("开始: 发现可疑快照")) --> B["物理损坏(Allocation Errors)"]
|
||||
```
|
||||
|
||||
### 7.3 包含 HTML 标签 (v1.1.0+)
|
||||
**Before**:
|
||||
```mermaid
|
||||
graph TD
|
||||
A[第一步<br/>环境隔离] --> B{状态?}
|
||||
```
|
||||
**After**:
|
||||
```mermaid
|
||||
graph TD
|
||||
A["第一步<br/>环境隔离"] --> B{"状态?"}
|
||||
```
|
||||
*注:插件已优化 HTML 保护机制,允许包含 `<br/>` 等标签的 Mermaid 图表正常触发修复。*
|
||||
|
||||
## 8. XML 标签清理 (XML Cleanup)
|
||||
|
||||
移除 LLM 输出中残留的无用 XML 标签(如 Claude 的 artifact 标签)。
|
||||
|
||||
**Before:**
|
||||
Here is the result <antArtifact>hidden metadata</antArtifact>.
|
||||
|
||||
**After:**
|
||||
## 9. 标题格式修复 (Heading Format Fix)
|
||||
**功能**: 修复标题标记 `#` 后缺少空格的问题。
|
||||
**默认**: 开启 (`enable_heading_fix = True`)
|
||||
**示例**:
|
||||
* **Before**: `#Heading 1`
|
||||
* **After**: `# Heading 1`
|
||||
|
||||
## 10. 表格格式修复 (Table Format Fix)
|
||||
**功能**: 修复表格行末尾缺少管道符 `|` 的问题。
|
||||
**默认**: 开启 (`enable_table_fix = True`)
|
||||
**示例**:
|
||||
* **Before**: `| Col 1 | Col 2`
|
||||
* **After**: `| Col 1 | Col 2 |`
|
||||
61
plugins/filters/markdown_normalizer/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Markdown Normalizer Filter
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
**Version:** 1.1.2
|
||||
|
||||
A content normalizer filter for Open WebUI that fixes common Markdown formatting issues in LLM outputs. It ensures that code blocks, LaTeX formulas, Mermaid diagrams, and other Markdown elements are rendered correctly.
|
||||
|
||||
## Features
|
||||
|
||||
* **Mermaid Syntax Fix**: Automatically fixes common Mermaid syntax errors, such as unquoted node labels (including multi-line labels and citations) and unclosed subgraphs. **New in v1.1.2**: Comprehensive protection for edge labels (text on connecting lines) across all link types (solid, dotted, thick).
|
||||
* **Frontend Console Debugging**: Supports printing structured debug logs directly to the browser console (F12) for easier troubleshooting.
|
||||
* **Code Block Formatting**: Fixes broken code block prefixes, suffixes, and indentation.
|
||||
* **LaTeX Normalization**: Standardizes LaTeX formula delimiters (`\[` -> `$$`, `\(` -> `$`).
|
||||
* **Thought Tag Normalization**: Unifies thought tags (`<think>`, `<thinking>` -> `<thought>`).
|
||||
* **Escape Character Fix**: Cleans up excessive escape characters (`\\n`, `\\t`).
|
||||
* **List Formatting**: Ensures proper newlines in list items.
|
||||
* **Heading Fix**: Adds missing spaces in headings (`#Heading` -> `# Heading`).
|
||||
* **Table Fix**: Adds missing closing pipes in tables.
|
||||
* **XML Cleanup**: Removes leftover XML artifacts.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install the plugin in Open WebUI.
|
||||
2. Enable the filter globally or for specific models.
|
||||
3. Configure the enabled fixes in the **Valves** settings.
|
||||
4. (Optional) **Show Debug Log** is enabled by default in Valves. This prints structured logs to the browser console (F12).
|
||||
> [!WARNING]
|
||||
> As this is an initial version, some "negative fixes" might occur (e.g., breaking valid Markdown). If you encounter issues, please check the console logs, copy the "Original" vs "Normalized" content, and submit an issue.
|
||||
|
||||
## Configuration (Valves)
|
||||
|
||||
* `priority`: Filter priority (default: 50).
|
||||
* `enable_escape_fix`: Fix excessive escape characters.
|
||||
* `enable_thought_tag_fix`: Normalize thought tags.
|
||||
* `enable_code_block_fix`: Fix code block formatting.
|
||||
* `enable_latex_fix`: Normalize LaTeX formulas.
|
||||
* `enable_list_fix`: Fix list item newlines (Experimental).
|
||||
* `enable_unclosed_block_fix`: Auto-close unclosed code blocks.
|
||||
* `enable_fullwidth_symbol_fix`: Fix full-width symbols in code blocks.
|
||||
* `enable_mermaid_fix`: Fix Mermaid syntax errors.
|
||||
* `enable_heading_fix`: Fix missing space in headings.
|
||||
* `enable_table_fix`: Fix missing closing pipe in tables.
|
||||
* `enable_xml_tag_cleanup`: Cleanup leftover XML tags.
|
||||
* `show_status`: Show status notification when fixes are applied.
|
||||
* `show_debug_log`: Print debug logs to browser console.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.1.2
|
||||
* **Mermaid Edge Label Protection**: Implemented comprehensive protection for edge labels (text on connecting lines) to prevent them from being incorrectly modified. Now supports all Mermaid link types including solid (`--`), dotted (`-.`), and thick (`==`) lines with or without arrows.
|
||||
* **Bug Fixes**: Fixed an issue where lines without arrows (e.g., `A -- text --- B`) were not correctly protected.
|
||||
|
||||
### v1.1.0
|
||||
* **Mermaid Fix Refinement**: Improved regex to handle nested parentheses in node labels (e.g., `ID("Label (text)")`) and avoided matching connection labels.
|
||||
* **HTML Safeguard Optimization**: Refined `_contains_html` to allow common tags like `<br/>`, `<b>`, `<i>`, etc., ensuring Mermaid diagrams with these tags are still normalized.
|
||||
* **Full-width Symbol Cleanup**: Fixed duplicate keys and incorrect quote mapping in `FULLWIDTH_MAP`.
|
||||
* **Bug Fixes**: Fixed missing `Dict` import in Python files.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
61
plugins/filters/markdown_normalizer/README_CN.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Markdown 格式化过滤器 (Markdown Normalizer)
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
**版本:** 1.1.2
|
||||
|
||||
这是一个用于 Open WebUI 的内容格式化过滤器,旨在修复 LLM 输出中常见的 Markdown 格式问题。它能确保代码块、LaTeX 公式、Mermaid 图表和其他 Markdown 元素被正确渲染。
|
||||
|
||||
## 功能特性
|
||||
|
||||
* **Mermaid 语法修复**: 自动修复常见的 Mermaid 语法错误,如未加引号的节点标签(支持多行标签和引用标记)和未闭合的子图 (Subgraph)。**v1.1.2 新增**: 全面保护各种类型的连线标签(实线、虚线、粗线),防止被误修改。
|
||||
* **前端控制台调试**: 支持将结构化的调试日志直接打印到浏览器控制台 (F12),方便排查问题。
|
||||
* **代码块格式化**: 修复破损的代码块前缀、后缀和缩进问题。
|
||||
* **LaTeX 规范化**: 标准化 LaTeX 公式定界符 (`\[` -> `$$`, `\(` -> `$`)。
|
||||
* **思维标签规范化**: 统一思维链标签 (`<think>`, `<thinking>` -> `<thought>`)。
|
||||
* **转义字符修复**: 清理过度的转义字符 (`\\n`, `\\t`)。
|
||||
* **列表格式化**: 确保列表项有正确的换行。
|
||||
* **标题修复**: 修复标题中缺失的空格 (`#标题` -> `# 标题`)。
|
||||
* **表格修复**: 修复表格中缺失的闭合管道符。
|
||||
* **XML 清理**: 移除残留的 XML 标签。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 在 Open WebUI 中安装此插件。
|
||||
2. 全局启用或为特定模型启用此过滤器。
|
||||
3. 在 **Valves** 设置中配置需要启用的修复项。
|
||||
4. (可选) **显示调试日志 (Show Debug Log)** 在 Valves 中默认开启。这会将结构化的日志打印到浏览器控制台 (F12)。
|
||||
> [!WARNING]
|
||||
> 由于这是初版,可能会出现“负向修复”的情况(例如破坏了原本正确的格式)。如果您遇到问题,请务必查看控制台日志,复制“原始 (Original)”与“规范化 (Normalized)”的内容对比,并提交 Issue 反馈。
|
||||
|
||||
## 配置项 (Valves)
|
||||
|
||||
* `priority`: 过滤器优先级 (默认: 50)。
|
||||
* `enable_escape_fix`: 修复过度的转义字符。
|
||||
* `enable_thought_tag_fix`: 规范化思维标签。
|
||||
* `enable_code_block_fix`: 修复代码块格式。
|
||||
* `enable_latex_fix`: 规范化 LaTeX 公式。
|
||||
* `enable_list_fix`: 修复列表项换行 (实验性)。
|
||||
* `enable_unclosed_block_fix`: 自动闭合未闭合的代码块。
|
||||
* `enable_fullwidth_symbol_fix`: 修复代码块中的全角符号。
|
||||
* `enable_mermaid_fix`: 修复 Mermaid 语法错误。
|
||||
* `enable_heading_fix`: 修复标题中缺失的空格。
|
||||
* `enable_table_fix`: 修复表格中缺失的闭合管道符。
|
||||
* `enable_xml_tag_cleanup`: 清理残留的 XML 标签。
|
||||
* `show_status`: 应用修复时显示状态通知。
|
||||
* `show_debug_log`: 在浏览器控制台打印调试日志。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.1.2
|
||||
* **Mermaid 连线标签保护**: 实现了全面的连线标签保护机制,防止连接线上的文字被误修改。现在支持所有 Mermaid 连线类型,包括实线 (`--`)、虚线 (`-.`) 和粗线 (`==`),无论是否带有箭头。
|
||||
* **Bug 修复**: 修复了无箭头连线(如 `A -- text --- B`)未被正确保护的问题。
|
||||
|
||||
### v1.1.0
|
||||
* **Mermaid 修复优化**: 改进了正则表达式以处理节点标签中的嵌套括号(如 `ID("标签 (文本)")`),并避免误匹配连接线上的文字。
|
||||
* **HTML 保护机制优化**: 优化了 `_contains_html` 检测,允许 `<br/>`, `<b>`, `<i>` 等常见标签,确保包含这些标签的 Mermaid 图表能被正常规范化。
|
||||
* **全角符号清理**: 修复了 `FULLWIDTH_MAP` 中的重复键名和错误的引号映射。
|
||||
* **Bug 修复**: 修复了 Python 文件中缺失的 `Dict` 类型导入。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
BIN
plugins/filters/markdown_normalizer/markdown_normalizer.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
627
plugins/filters/markdown_normalizer/markdown_normalizer.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
title: Markdown Normalizer
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 1.1.2
|
||||
description: A content normalizer filter that fixes common Markdown formatting issues in LLM outputs, such as broken code blocks, LaTeX formulas, and list formatting.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Callable, Dict
|
||||
import re
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizerConfig:
|
||||
"""Configuration class for enabling/disabling specific normalization rules"""
|
||||
|
||||
enable_escape_fix: bool = True # Fix excessive escape characters
|
||||
enable_escape_fix_in_code_blocks: bool = (
|
||||
False # Apply escape fix inside code blocks (default: False for safety)
|
||||
)
|
||||
enable_thought_tag_fix: bool = True # Normalize thought tags
|
||||
enable_code_block_fix: bool = True # Fix code block formatting
|
||||
enable_latex_fix: bool = True # Fix LaTeX formula formatting
|
||||
enable_list_fix: bool = (
|
||||
False # Fix list item newlines (default off as it can be aggressive)
|
||||
)
|
||||
enable_unclosed_block_fix: bool = True # Auto-close unclosed code blocks
|
||||
enable_fullwidth_symbol_fix: bool = False # Fix full-width symbols in code blocks
|
||||
enable_mermaid_fix: bool = True # Fix common Mermaid syntax errors
|
||||
enable_heading_fix: bool = (
|
||||
True # Fix missing space in headings (#Header -> # Header)
|
||||
)
|
||||
enable_table_fix: bool = True # Fix missing closing pipe in tables
|
||||
enable_xml_tag_cleanup: bool = True # Cleanup leftover XML tags
|
||||
|
||||
# Custom cleaner functions (for advanced extension)
|
||||
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
|
||||
|
||||
|
||||
class ContentNormalizer:
|
||||
"""LLM Output Content Normalizer - Production Grade Implementation"""
|
||||
|
||||
# --- 1. Pre-compiled Regex Patterns (Performance Optimization) ---
|
||||
_PATTERNS = {
|
||||
# Code block prefix: if ``` is not at start of line or file
|
||||
"code_block_prefix": re.compile(r"(?<!^)(?<!\n)(```)", re.MULTILINE),
|
||||
# Code block suffix: ```lang followed by non-whitespace (no newline)
|
||||
"code_block_suffix": re.compile(r"(```[\w\+\-\.]*)[ \t]+([^\n\r])"),
|
||||
# Code block indent: whitespace at start of line + ```
|
||||
"code_block_indent": re.compile(r"^[ \t]+(```)", re.MULTILINE),
|
||||
# Thought tag: </thought> followed by optional whitespace/newlines
|
||||
"thought_end": re.compile(
|
||||
r"</(thought|think|thinking)>[ \t]*\n*", re.IGNORECASE
|
||||
),
|
||||
"thought_start": re.compile(r"<(thought|think|thinking)>", re.IGNORECASE),
|
||||
# LaTeX block: \[ ... \]
|
||||
"latex_bracket_block": re.compile(r"\\\[(.+?)\\\]", re.DOTALL),
|
||||
# LaTeX inline: \( ... \)
|
||||
"latex_paren_inline": re.compile(r"\\\((.+?)\\\)"),
|
||||
# List item: non-newline + digit + dot + space
|
||||
"list_item": re.compile(r"([^\n])(\d+\. )"),
|
||||
# XML artifacts (e.g. Claude's)
|
||||
"xml_artifacts": re.compile(
|
||||
r"</?(?:antArtifact|antThinking|artifact)[^>]*>", re.IGNORECASE
|
||||
),
|
||||
# Mermaid: Match various node shapes and quote unquoted labels
|
||||
# Fix "reverse optimization": Must precisely match shape delimiters to avoid breaking structure
|
||||
# Priority: Longer delimiters match first
|
||||
"mermaid_node": re.compile(
|
||||
r'("[^"\\]*(?:\\.[^"\\]*)*")|' # Match quoted strings first (Group 1)
|
||||
r"(\w+)(?:"
|
||||
r"(\(\(\()(?![\"])(.*?)(?<![\"])(\)\)\))|" # (((...))) Double Circle
|
||||
r"(\(\()(?![\"])(.*?)(?<![\"])(\)\))|" # ((...)) Circle
|
||||
r"(\(\[)(?![\"])(.*?)(?<![\"])(\]\))|" # ([...]) Stadium
|
||||
r"(\[\()(?![\"])(.*?)(?<![\"])(\)\])|" # [(...)] Cylinder
|
||||
r"(\[\[)(?![\"])(.*?)(?<![\"])(\]\])|" # [[...]] Subroutine
|
||||
r"(\{\{)(?![\"])(.*?)(?<![\"])(\}\})|" # {{...}} Hexagon
|
||||
r"(\[/)(?![\"])(.*?)(?<![\"])(/\])|" # [/.../] Parallelogram
|
||||
r"(\[\\)(?![\"])(.*?)(?<![\"])(\\\])|" # [\...\] Parallelogram Alt
|
||||
r"(\[/)(?![\"])(.*?)(?<![\"])(\\\])|" # [/...\] Trapezoid
|
||||
r"(\[\\)(?![\"])(.*?)(?<![\"])(/\])|" # [\.../] Trapezoid Alt
|
||||
r"(\()(?![\"])([^)]*?)(?<![\"])(\))|" # (...) Round - Modified to be safer
|
||||
r"(\[)(?![\"])(.*?)(?<![\"])(\])|" # [...] Square
|
||||
r"(\{)(?![\"])(.*?)(?<![\"])(\})|" # {...} Rhombus
|
||||
r"(>)(?![\"])(.*?)(?<![\"])(\])" # >...] Asymmetric
|
||||
r")"
|
||||
r"(\s*\[\d+\])?", # Capture optional citation [1]
|
||||
re.DOTALL,
|
||||
),
|
||||
# Heading: #Heading -> # Heading
|
||||
"heading_space": re.compile(r"^(#+)([^ \n#])", re.MULTILINE),
|
||||
# Table: | col1 | col2 -> | col1 | col2 |
|
||||
"table_pipe": re.compile(r"^(\|.*[^|\n])$", re.MULTILINE),
|
||||
}
|
||||
|
||||
def __init__(self, config: Optional[NormalizerConfig] = None):
|
||||
self.config = config or NormalizerConfig()
|
||||
self.applied_fixes = []
|
||||
|
||||
def normalize(self, content: str) -> str:
|
||||
"""Main entry point: apply all normalization rules in order"""
|
||||
self.applied_fixes = []
|
||||
if not content:
|
||||
return content
|
||||
|
||||
original_content = content # Keep a copy for logging
|
||||
|
||||
try:
|
||||
# 1. Escape character fix (Must be first)
|
||||
if self.config.enable_escape_fix:
|
||||
original = content
|
||||
content = self._fix_escape_characters(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Escape Chars")
|
||||
|
||||
# 2. Thought tag normalization
|
||||
if self.config.enable_thought_tag_fix:
|
||||
original = content
|
||||
content = self._fix_thought_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Normalize Thought Tags")
|
||||
|
||||
# 3. Code block formatting fix
|
||||
if self.config.enable_code_block_fix:
|
||||
original = content
|
||||
content = self._fix_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Code Blocks")
|
||||
|
||||
# 4. LaTeX formula normalization
|
||||
if self.config.enable_latex_fix:
|
||||
original = content
|
||||
content = self._fix_latex_formulas(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Normalize LaTeX")
|
||||
|
||||
# 5. List formatting fix
|
||||
if self.config.enable_list_fix:
|
||||
original = content
|
||||
content = self._fix_list_formatting(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix List Format")
|
||||
|
||||
# 6. Unclosed code block fix
|
||||
if self.config.enable_unclosed_block_fix:
|
||||
original = content
|
||||
content = self._fix_unclosed_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Close Code Blocks")
|
||||
|
||||
# 7. Full-width symbol fix (in code blocks only)
|
||||
if self.config.enable_fullwidth_symbol_fix:
|
||||
original = content
|
||||
content = self._fix_fullwidth_symbols_in_code(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Full-width Symbols")
|
||||
|
||||
# 8. Mermaid syntax fix
|
||||
if self.config.enable_mermaid_fix:
|
||||
original = content
|
||||
content = self._fix_mermaid_syntax(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Mermaid Syntax")
|
||||
|
||||
# 9. Heading fix
|
||||
if self.config.enable_heading_fix:
|
||||
original = content
|
||||
content = self._fix_headings(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Headings")
|
||||
|
||||
# 10. Table fix
|
||||
if self.config.enable_table_fix:
|
||||
original = content
|
||||
content = self._fix_tables(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Tables")
|
||||
|
||||
# 11. XML tag cleanup
|
||||
if self.config.enable_xml_tag_cleanup:
|
||||
original = content
|
||||
content = self._cleanup_xml_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Cleanup XML Tags")
|
||||
|
||||
# 9. Custom cleaners
|
||||
for cleaner in self.config.custom_cleaners:
|
||||
original = content
|
||||
content = cleaner(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Custom Cleaner")
|
||||
|
||||
if self.applied_fixes:
|
||||
logger.info(f"Markdown Normalizer Applied Fixes: {self.applied_fixes}")
|
||||
logger.debug(
|
||||
f"--- Original Content ---\n{original_content}\n------------------------"
|
||||
)
|
||||
logger.debug(
|
||||
f"--- Normalized Content ---\n{content}\n--------------------------"
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
# Production safeguard: return original content on error
|
||||
logger.error(f"Content normalization failed: {e}", exc_info=True)
|
||||
return content
|
||||
|
||||
def _fix_escape_characters(self, content: str) -> str:
|
||||
"""Fix excessive escape characters
|
||||
|
||||
If enable_escape_fix_in_code_blocks is False (default), this method will only
|
||||
fix escape characters outside of code blocks to avoid breaking valid code
|
||||
examples (e.g., JSON strings with \\n, regex patterns, etc.).
|
||||
"""
|
||||
if self.config.enable_escape_fix_in_code_blocks:
|
||||
# Apply globally (original behavior)
|
||||
content = content.replace("\\r\\n", "\n")
|
||||
content = content.replace("\\n", "\n")
|
||||
content = content.replace("\\t", "\t")
|
||||
content = content.replace("\\\\", "\\")
|
||||
return content
|
||||
else:
|
||||
# Apply only outside code blocks (safe mode)
|
||||
parts = content.split("```")
|
||||
for i in range(
|
||||
0, len(parts), 2
|
||||
): # Even indices are markdown text (not code)
|
||||
parts[i] = parts[i].replace("\\r\\n", "\n")
|
||||
parts[i] = parts[i].replace("\\n", "\n")
|
||||
parts[i] = parts[i].replace("\\t", "\t")
|
||||
parts[i] = parts[i].replace("\\\\", "\\")
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_thought_tags(self, content: str) -> str:
|
||||
"""Normalize thought tags: unify naming and fix spacing"""
|
||||
# 1. Standardize start tag: <think>, <thinking> -> <thought>
|
||||
content = self._PATTERNS["thought_start"].sub("<thought>", content)
|
||||
# 2. Standardize end tag and ensure newlines: </think> -> </thought>\n\n
|
||||
return self._PATTERNS["thought_end"].sub("</thought>\n\n", content)
|
||||
|
||||
def _fix_code_blocks(self, content: str) -> str:
|
||||
"""Fix code block formatting (prefixes, suffixes, indentation)"""
|
||||
# Remove indentation before code blocks
|
||||
content = self._PATTERNS["code_block_indent"].sub(r"\1", content)
|
||||
# Ensure newline before ```
|
||||
content = self._PATTERNS["code_block_prefix"].sub(r"\n\1", content)
|
||||
# Ensure newline after ```lang
|
||||
content = self._PATTERNS["code_block_suffix"].sub(r"\1\n\2", content)
|
||||
return content
|
||||
|
||||
def _fix_latex_formulas(self, content: str) -> str:
|
||||
r"""Normalize LaTeX formulas: \[ -> $$ (block), \( -> $ (inline)"""
|
||||
content = self._PATTERNS["latex_bracket_block"].sub(r"$$\1$$", content)
|
||||
content = self._PATTERNS["latex_paren_inline"].sub(r"$\1$", content)
|
||||
return content
|
||||
|
||||
def _fix_list_formatting(self, content: str) -> str:
|
||||
"""Fix missing newlines in lists (e.g., 'text1. item' -> 'text\\n1. item')"""
|
||||
return self._PATTERNS["list_item"].sub(r"\1\n\2", content)
|
||||
|
||||
def _fix_unclosed_code_blocks(self, content: str) -> str:
|
||||
"""Auto-close unclosed code blocks"""
|
||||
if content.count("```") % 2 != 0:
|
||||
content += "\n```"
|
||||
return content
|
||||
|
||||
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
|
||||
"""Convert full-width symbols to half-width inside code blocks"""
|
||||
FULLWIDTH_MAP = {
|
||||
",": ",",
|
||||
"。": ".",
|
||||
"(": "(",
|
||||
")": ")",
|
||||
"【": "[",
|
||||
"】": "]",
|
||||
";": ";",
|
||||
":": ":",
|
||||
"?": "?",
|
||||
"!": "!",
|
||||
""": '"', # U+FF02 FULLWIDTH QUOTATION MARK
|
||||
"'": "'", # U+FF07 FULLWIDTH APOSTROPHE
|
||||
"“": '"',
|
||||
"”": '"',
|
||||
"‘": "'",
|
||||
"’": "'",
|
||||
}
|
||||
|
||||
parts = content.split("```")
|
||||
# Code block content is at odd indices: 1, 3, 5...
|
||||
for i in range(1, len(parts), 2):
|
||||
for full, half in FULLWIDTH_MAP.items():
|
||||
parts[i] = parts[i].replace(full, half)
|
||||
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_mermaid_syntax(self, content: str) -> str:
|
||||
"""Fix common Mermaid syntax errors while preserving node shapes"""
|
||||
|
||||
def replacer(match):
|
||||
# Group 1 is Quoted String (if matched)
|
||||
if match.group(1):
|
||||
return match.group(1)
|
||||
|
||||
# Group 2 is ID
|
||||
id_str = match.group(2)
|
||||
|
||||
# Find matching shape group
|
||||
groups = match.groups()
|
||||
citation = groups[-1] or "" # Last group is citation
|
||||
|
||||
# Iterate over shape groups (excluding the last citation group)
|
||||
for i in range(2, len(groups) - 1, 3):
|
||||
if groups[i] is not None:
|
||||
open_char = groups[i]
|
||||
content = groups[i + 1]
|
||||
close_char = groups[i + 2]
|
||||
|
||||
# Append citation to content if present
|
||||
if citation:
|
||||
content += citation
|
||||
|
||||
# Escape quotes in content
|
||||
content = content.replace('"', '\\"')
|
||||
|
||||
return f'{id_str}{open_char}"{content}"{close_char}'
|
||||
|
||||
return match.group(0)
|
||||
|
||||
parts = content.split("```")
|
||||
for i in range(1, len(parts), 2):
|
||||
# Check if it's a mermaid block
|
||||
lang_line = parts[i].split("\n", 1)[0].strip().lower()
|
||||
if "mermaid" in lang_line:
|
||||
# Protect edge labels (text between link start and arrow) from being modified
|
||||
# by temporarily replacing them with placeholders.
|
||||
# Covers all Mermaid link types:
|
||||
# - Solid line: A -- text --> B, A -- text --o B, A -- text --x B
|
||||
# - Dotted line: A -. text .-> B, A -. text .-o B
|
||||
# - Thick line: A == text ==> B, A == text ==o B
|
||||
# - No arrow: A -- text --- B
|
||||
edge_labels = []
|
||||
|
||||
def protect_edge_label(m):
|
||||
start = m.group(1) # Link start: --, -., or ==
|
||||
label = m.group(2) # Text content
|
||||
arrow = m.group(3) # Arrow/end pattern
|
||||
edge_labels.append((start, label, arrow))
|
||||
return f"___EDGE_LABEL_{len(edge_labels)-1}___"
|
||||
|
||||
# Comprehensive edge label pattern for all Mermaid link types
|
||||
edge_label_pattern = (
|
||||
r"(--|-\.|\=\=)\s+(.+?)\s+(--+[>ox]?|--+\|>|\.-[>ox]?|=+[>ox]?)"
|
||||
)
|
||||
protected = re.sub(edge_label_pattern, protect_edge_label, parts[i])
|
||||
|
||||
# Apply the comprehensive regex fix to protected content
|
||||
fixed = self._PATTERNS["mermaid_node"].sub(replacer, protected)
|
||||
|
||||
# Restore edge labels
|
||||
for idx, (start, label, arrow) in enumerate(edge_labels):
|
||||
fixed = fixed.replace(
|
||||
f"___EDGE_LABEL_{idx}___", f"{start} {label} {arrow}"
|
||||
)
|
||||
|
||||
parts[i] = fixed
|
||||
|
||||
# Auto-close subgraphs
|
||||
subgraph_count = len(
|
||||
re.findall(r"\bsubgraph\b", parts[i], re.IGNORECASE)
|
||||
)
|
||||
end_count = len(re.findall(r"\bend\b", parts[i], re.IGNORECASE))
|
||||
|
||||
if subgraph_count > end_count:
|
||||
missing_ends = subgraph_count - end_count
|
||||
parts[i] = parts[i].rstrip() + ("\n end" * missing_ends) + "\n"
|
||||
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_headings(self, content: str) -> str:
|
||||
"""Fix missing space in headings: #Heading -> # Heading"""
|
||||
# We only fix if it's not inside a code block.
|
||||
# But splitting by code block is expensive.
|
||||
# Given headings usually don't appear inside code blocks without space in valid code (except comments),
|
||||
# we might risk false positives in comments like `#TODO`.
|
||||
# To be safe, let's split by code blocks.
|
||||
|
||||
parts = content.split("```")
|
||||
for i in range(0, len(parts), 2): # Even indices are markdown text
|
||||
parts[i] = self._PATTERNS["heading_space"].sub(r"\1 \2", parts[i])
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_tables(self, content: str) -> str:
|
||||
"""Fix tables missing closing pipe"""
|
||||
parts = content.split("```")
|
||||
for i in range(0, len(parts), 2):
|
||||
parts[i] = self._PATTERNS["table_pipe"].sub(r"\1|", parts[i])
|
||||
return "```".join(parts)
|
||||
|
||||
def _cleanup_xml_tags(self, content: str) -> str:
|
||||
"""Remove leftover XML tags"""
|
||||
return self._PATTERNS["xml_artifacts"].sub("", content)
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
priority: int = Field(
|
||||
default=50,
|
||||
description="Priority level. Higher runs later (recommended to run after other filters).",
|
||||
)
|
||||
enable_escape_fix: bool = Field(
|
||||
default=True, description="Fix excessive escape characters (\\n, \\t, etc.)"
|
||||
)
|
||||
enable_escape_fix_in_code_blocks: bool = Field(
|
||||
default=False,
|
||||
description="Apply escape fix inside code blocks (⚠️ Warning: May break valid code like JSON strings or regex patterns. Default: False for safety)",
|
||||
)
|
||||
enable_thought_tag_fix: bool = Field(
|
||||
default=True, description="Normalize </thought> tags"
|
||||
)
|
||||
enable_code_block_fix: bool = Field(
|
||||
default=True,
|
||||
description="Fix code block formatting (indentation, newlines)",
|
||||
)
|
||||
enable_latex_fix: bool = Field(
|
||||
default=True, description="Normalize LaTeX formulas (\\[ -> $$, \\( -> $)"
|
||||
)
|
||||
enable_list_fix: bool = Field(
|
||||
default=False, description="Fix list item newlines (Experimental)"
|
||||
)
|
||||
enable_unclosed_block_fix: bool = Field(
|
||||
default=True, description="Auto-close unclosed code blocks"
|
||||
)
|
||||
enable_fullwidth_symbol_fix: bool = Field(
|
||||
default=False, description="Fix full-width symbols in code blocks"
|
||||
)
|
||||
enable_mermaid_fix: bool = Field(
|
||||
default=True,
|
||||
description="Fix common Mermaid syntax errors (e.g. unquoted labels)",
|
||||
)
|
||||
enable_heading_fix: bool = Field(
|
||||
default=True,
|
||||
description="Fix missing space in headings (#Header -> # Header)",
|
||||
)
|
||||
enable_table_fix: bool = Field(
|
||||
default=True, description="Fix missing closing pipe in tables"
|
||||
)
|
||||
enable_xml_tag_cleanup: bool = Field(
|
||||
default=True, description="Cleanup leftover XML tags"
|
||||
)
|
||||
show_status: bool = Field(
|
||||
default=True, description="Show status notification when fixes are applied"
|
||||
)
|
||||
show_debug_log: bool = Field(
|
||||
default=True, description="Print debug logs to browser console (F12)"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _get_chat_context(
|
||||
self, body: dict, __metadata__: Optional[dict] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Unified extraction of chat context information (chat_id, message_id).
|
||||
Prioritizes extraction from body, then metadata.
|
||||
"""
|
||||
chat_id = ""
|
||||
message_id = ""
|
||||
|
||||
# 1. Try to get from body
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id", "")
|
||||
message_id = body.get("id", "") # message_id is usually 'id' in body
|
||||
|
||||
# Check body.metadata as fallback
|
||||
if not chat_id or not message_id:
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
if not chat_id:
|
||||
chat_id = body_metadata.get("chat_id", "")
|
||||
if not message_id:
|
||||
message_id = body_metadata.get("message_id", "")
|
||||
|
||||
# 2. Try to get from __metadata__ (as supplement)
|
||||
if __metadata__ and isinstance(__metadata__, dict):
|
||||
if not chat_id:
|
||||
chat_id = __metadata__.get("chat_id", "")
|
||||
if not message_id:
|
||||
message_id = __metadata__.get("message_id", "")
|
||||
|
||||
return {
|
||||
"chat_id": str(chat_id).strip(),
|
||||
"message_id": str(message_id).strip(),
|
||||
}
|
||||
|
||||
def _contains_html(self, content: str) -> bool:
|
||||
"""Check if content contains HTML tags (to avoid breaking HTML output)"""
|
||||
# Removed common Mermaid-compatible tags like br, b, i, strong, em, span
|
||||
pattern = r"<\s*/?\s*(?:html|head|body|div|p|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
|
||||
return bool(re.search(pattern, content, re.IGNORECASE))
|
||||
|
||||
async def _emit_status(self, __event_emitter__, applied_fixes: List[str]):
|
||||
"""Emit status notification"""
|
||||
if not self.valves.show_status or not applied_fixes:
|
||||
return
|
||||
|
||||
description = "✓ Markdown Normalized"
|
||||
if applied_fixes:
|
||||
description += f": {', '.join(applied_fixes)}"
|
||||
|
||||
try:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": description,
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error emitting status: {e}")
|
||||
|
||||
async def _emit_debug_log(
|
||||
self,
|
||||
__event_call__,
|
||||
applied_fixes: List[str],
|
||||
original: str,
|
||||
normalized: str,
|
||||
chat_id: str = "",
|
||||
):
|
||||
"""Emit debug log to browser console via JS execution"""
|
||||
if not self.valves.show_debug_log or not __event_call__:
|
||||
return
|
||||
|
||||
try:
|
||||
# Construct JS code
|
||||
js_code = f"""
|
||||
(async function() {{
|
||||
console.group("🛠️ Markdown Normalizer Debug");
|
||||
console.log("Chat ID:", {json.dumps(chat_id)});
|
||||
console.log("Applied Fixes:", {json.dumps(applied_fixes, ensure_ascii=False)});
|
||||
console.log("Original Content:", {json.dumps(original, ensure_ascii=False)});
|
||||
console.log("Normalized Content:", {json.dumps(normalized, ensure_ascii=False)});
|
||||
console.groupEnd();
|
||||
}})();
|
||||
"""
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error emitting debug log: {e}")
|
||||
|
||||
async def outlet(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[dict] = None,
|
||||
__event_emitter__=None,
|
||||
__event_call__=None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Process the response body to normalize Markdown content.
|
||||
"""
|
||||
if "messages" in body and body["messages"]:
|
||||
last = body["messages"][-1]
|
||||
content = last.get("content", "") or ""
|
||||
|
||||
if last.get("role") == "assistant" and isinstance(content, str):
|
||||
# Skip if content looks like HTML to avoid breaking it
|
||||
if self._contains_html(content):
|
||||
return body
|
||||
|
||||
# Configure normalizer based on valves
|
||||
config = NormalizerConfig(
|
||||
enable_escape_fix=self.valves.enable_escape_fix,
|
||||
enable_escape_fix_in_code_blocks=self.valves.enable_escape_fix_in_code_blocks,
|
||||
enable_thought_tag_fix=self.valves.enable_thought_tag_fix,
|
||||
enable_code_block_fix=self.valves.enable_code_block_fix,
|
||||
enable_latex_fix=self.valves.enable_latex_fix,
|
||||
enable_list_fix=self.valves.enable_list_fix,
|
||||
enable_unclosed_block_fix=self.valves.enable_unclosed_block_fix,
|
||||
enable_fullwidth_symbol_fix=self.valves.enable_fullwidth_symbol_fix,
|
||||
enable_mermaid_fix=self.valves.enable_mermaid_fix,
|
||||
enable_heading_fix=self.valves.enable_heading_fix,
|
||||
enable_table_fix=self.valves.enable_table_fix,
|
||||
enable_xml_tag_cleanup=self.valves.enable_xml_tag_cleanup,
|
||||
)
|
||||
|
||||
normalizer = ContentNormalizer(config)
|
||||
|
||||
# Execute normalization
|
||||
new_content = normalizer.normalize(content)
|
||||
|
||||
# Update content if changed
|
||||
if new_content != content:
|
||||
last["content"] = new_content
|
||||
|
||||
# Emit status if enabled
|
||||
if __event_emitter__:
|
||||
await self._emit_status(
|
||||
__event_emitter__, normalizer.applied_fixes
|
||||
)
|
||||
chat_ctx = self._get_chat_context(body, __metadata__)
|
||||
await self._emit_debug_log(
|
||||
__event_call__,
|
||||
normalizer.applied_fixes,
|
||||
content,
|
||||
new_content,
|
||||
chat_id=chat_ctx["chat_id"],
|
||||
)
|
||||
|
||||
return body
|
||||
BIN
plugins/filters/markdown_normalizer/markdown_normalizer_cn.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
616
plugins/filters/markdown_normalizer/markdown_normalizer_cn.py
Normal file
@@ -0,0 +1,616 @@
|
||||
"""
|
||||
title: Markdown 格式修复器 (Markdown Normalizer)
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
funding_url: https://github.com/open-webui
|
||||
version: 1.1.2
|
||||
description: 内容规范化过滤器,修复 LLM 输出中常见的 Markdown 格式问题,如损坏的代码块、LaTeX 公式、Mermaid 图表和列表格式。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Callable, Dict
|
||||
import re
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizerConfig:
|
||||
"""配置类,用于启用/禁用特定的规范化规则"""
|
||||
|
||||
enable_escape_fix: bool = True # 修复过度的转义字符
|
||||
enable_thought_tag_fix: bool = True # 规范化思维链标签
|
||||
enable_code_block_fix: bool = True # 修复代码块格式
|
||||
enable_latex_fix: bool = True # 修复 LaTeX 公式格式
|
||||
enable_list_fix: bool = False # 修复列表项换行 (默认关闭,因为可能过于激进)
|
||||
enable_unclosed_block_fix: bool = True # 自动闭合未闭合的代码块
|
||||
enable_fullwidth_symbol_fix: bool = False # 修复代码块中的全角符号
|
||||
enable_mermaid_fix: bool = True # 修复常见的 Mermaid 语法错误
|
||||
enable_heading_fix: bool = True # 修复标题中缺失的空格 (#Header -> # Header)
|
||||
enable_table_fix: bool = True # 修复表格中缺失的闭合管道符
|
||||
enable_xml_tag_cleanup: bool = True # 清理残留的 XML 标签
|
||||
|
||||
# 自定义清理函数 (用于高级扩展)
|
||||
custom_cleaners: List[Callable[[str], str]] = field(default_factory=list)
|
||||
|
||||
|
||||
class ContentNormalizer:
|
||||
"""LLM Output Content Normalizer - Production Grade Implementation"""
|
||||
|
||||
# --- 1. Pre-compiled Regex Patterns (Performance Optimization) ---
|
||||
_PATTERNS = {
|
||||
# Code block prefix: if ``` is not at start of line or file
|
||||
"code_block_prefix": re.compile(r"(?<!^)(?<!\n)(```)", re.MULTILINE),
|
||||
# Code block suffix: ```lang followed by non-whitespace (no newline)
|
||||
"code_block_suffix": re.compile(r"(```[\w\+\-\.]*)[ \t]+([^\n\r])"),
|
||||
# Code block indent: whitespace at start of line + ```
|
||||
"code_block_indent": re.compile(r"^[ \t]+(```)", re.MULTILINE),
|
||||
# Thought tag: </thought> followed by optional whitespace/newlines
|
||||
"thought_end": re.compile(
|
||||
r"</(thought|think|thinking)>[ \t]*\n*", re.IGNORECASE
|
||||
),
|
||||
"thought_start": re.compile(r"<(thought|think|thinking)>", re.IGNORECASE),
|
||||
# LaTeX block: \[ ... \]
|
||||
"latex_bracket_block": re.compile(r"\\\[(.+?)\\\]", re.DOTALL),
|
||||
# LaTeX inline: \( ... \)
|
||||
"latex_paren_inline": re.compile(r"\\\((.+?)\\\)"),
|
||||
# List item: non-newline + digit + dot + space
|
||||
"list_item": re.compile(r"([^\n])(\d+\. )"),
|
||||
# XML artifacts (e.g. Claude's)
|
||||
"xml_artifacts": re.compile(
|
||||
r"</?(?:antArtifact|antThinking|artifact)[^>]*>", re.IGNORECASE
|
||||
),
|
||||
# Mermaid: 匹配各种形状的节点并为未加引号的标签添加引号
|
||||
# 修复"反向优化"问题:必须精确匹配各种形状的定界符,避免破坏形状结构
|
||||
# 优先级:长定界符优先匹配
|
||||
"mermaid_node": re.compile(
|
||||
r'("[^"\\]*(?:\\.[^"\\]*)*")|' # Match quoted strings first (Group 1)
|
||||
r"(\w+)(?:"
|
||||
r"(\(\(\()(?![\"])(.*?)(?<![\"])(\)\)\))|" # (((...))) Double Circle
|
||||
r"(\(\()(?![\"])(.*?)(?<![\"])(\)\))|" # ((...)) Circle
|
||||
r"(\(\[)(?![\"])(.*?)(?<![\"])(\]\))|" # ([...]) Stadium
|
||||
r"(\[\()(?![\"])(.*?)(?<![\"])(\)\])|" # [(...)] Cylinder
|
||||
r"(\[\[)(?![\"])(.*?)(?<![\"])(\]\])|" # [[...]] Subroutine
|
||||
r"(\{\{)(?![\"])(.*?)(?<![\"])(\}\})|" # {{...}} Hexagon
|
||||
r"(\[/)(?![\"])(.*?)(?<![\"])(/\])|" # [/.../] Parallelogram
|
||||
r"(\[\\)(?![\"])(.*?)(?<![\"])(\\\])|" # [\...\] Parallelogram Alt
|
||||
r"(\[/)(?![\"])(.*?)(?<![\"])(\\\])|" # [/...\] Trapezoid
|
||||
r"(\[\\)(?![\"])(.*?)(?<![\"])(/\])|" # [\.../] Trapezoid Alt
|
||||
r"(\()(?![\"])([^)]*?)(?<![\"])(\))|" # (...) Round - Modified to be safer
|
||||
r"(\[)(?![\"])(.*?)(?<![\"])(\])|" # [...] Square
|
||||
r"(\{)(?![\"])(.*?)(?<![\"])(\})|" # {...} Rhombus
|
||||
r"(>)(?![\"])(.*?)(?<![\"])(\])" # >...] Asymmetric
|
||||
r")"
|
||||
r"(\s*\[\d+\])?", # Capture optional citation [1]
|
||||
re.DOTALL,
|
||||
),
|
||||
# Heading: #Heading -> # Heading
|
||||
"heading_space": re.compile(r"^(#+)([^ \n#])", re.MULTILINE),
|
||||
# Table: | col1 | col2 -> | col1 | col2 |
|
||||
"table_pipe": re.compile(r"^(\|.*[^|\n])$", re.MULTILINE),
|
||||
}
|
||||
|
||||
def __init__(self, config: Optional[NormalizerConfig] = None):
|
||||
self.config = config or NormalizerConfig()
|
||||
self.applied_fixes = []
|
||||
|
||||
def normalize(self, content: str) -> str:
|
||||
"""Main entry point: apply all normalization rules in order"""
|
||||
self.applied_fixes = []
|
||||
if not content:
|
||||
return content
|
||||
|
||||
original_content = content # Keep a copy for logging
|
||||
|
||||
try:
|
||||
# 1. Escape character fix (Must be first)
|
||||
if self.config.enable_escape_fix:
|
||||
original = content
|
||||
content = self._fix_escape_characters(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Escape Chars")
|
||||
|
||||
# 2. Thought tag normalization
|
||||
if self.config.enable_thought_tag_fix:
|
||||
original = content
|
||||
content = self._fix_thought_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Normalize Thought Tags")
|
||||
|
||||
# 3. Code block formatting fix
|
||||
if self.config.enable_code_block_fix:
|
||||
original = content
|
||||
content = self._fix_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Code Blocks")
|
||||
|
||||
# 4. LaTeX formula normalization
|
||||
if self.config.enable_latex_fix:
|
||||
original = content
|
||||
content = self._fix_latex_formulas(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Normalize LaTeX")
|
||||
|
||||
# 5. List formatting fix
|
||||
if self.config.enable_list_fix:
|
||||
original = content
|
||||
content = self._fix_list_formatting(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix List Format")
|
||||
|
||||
# 6. Unclosed code block fix
|
||||
if self.config.enable_unclosed_block_fix:
|
||||
original = content
|
||||
content = self._fix_unclosed_code_blocks(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Close Code Blocks")
|
||||
|
||||
# 7. Full-width symbol fix (in code blocks only)
|
||||
if self.config.enable_fullwidth_symbol_fix:
|
||||
original = content
|
||||
content = self._fix_fullwidth_symbols_in_code(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Full-width Symbols")
|
||||
|
||||
# 8. Mermaid syntax fix
|
||||
if self.config.enable_mermaid_fix:
|
||||
original = content
|
||||
content = self._fix_mermaid_syntax(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Mermaid Syntax")
|
||||
|
||||
# 9. Heading fix
|
||||
if self.config.enable_heading_fix:
|
||||
original = content
|
||||
content = self._fix_headings(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Headings")
|
||||
|
||||
# 10. Table fix
|
||||
if self.config.enable_table_fix:
|
||||
original = content
|
||||
content = self._fix_tables(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Fix Tables")
|
||||
|
||||
# 11. XML tag cleanup
|
||||
if self.config.enable_xml_tag_cleanup:
|
||||
original = content
|
||||
content = self._cleanup_xml_tags(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Cleanup XML Tags")
|
||||
|
||||
# 9. Custom cleaners
|
||||
for cleaner in self.config.custom_cleaners:
|
||||
original = content
|
||||
content = cleaner(content)
|
||||
if content != original:
|
||||
self.applied_fixes.append("Custom Cleaner")
|
||||
|
||||
if self.applied_fixes:
|
||||
print(f"[Markdown Normalizer] Applied fixes: {self.applied_fixes}")
|
||||
print(
|
||||
f"[Markdown Normalizer] --- Original Content ---\n{original_content}\n------------------------"
|
||||
)
|
||||
print(
|
||||
f"[Markdown Normalizer] --- Normalized Content ---\n{content}\n--------------------------"
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
# Production safeguard: return original content on error
|
||||
logger.error(f"Content normalization failed: {e}", exc_info=True)
|
||||
return content
|
||||
|
||||
def _fix_escape_characters(self, content: str) -> str:
|
||||
"""Fix excessive escape characters"""
|
||||
content = content.replace("\\r\\n", "\n")
|
||||
content = content.replace("\\n", "\n")
|
||||
content = content.replace("\\t", "\t")
|
||||
content = content.replace("\\\\", "\\")
|
||||
return content
|
||||
|
||||
def _fix_thought_tags(self, content: str) -> str:
|
||||
"""Normalize thought tags: unify naming and fix spacing"""
|
||||
# 1. Standardize start tag: <think>, <thinking> -> <thought>
|
||||
content = self._PATTERNS["thought_start"].sub("<thought>", content)
|
||||
# 2. Standardize end tag and ensure newlines: </think> -> </thought>\n\n
|
||||
return self._PATTERNS["thought_end"].sub("</thought>\n\n", content)
|
||||
|
||||
def _fix_code_blocks(self, content: str) -> str:
|
||||
"""Fix code block formatting (prefixes, suffixes, indentation)"""
|
||||
# Remove indentation before code blocks
|
||||
content = self._PATTERNS["code_block_indent"].sub(r"\1", content)
|
||||
# Ensure newline before ```
|
||||
content = self._PATTERNS["code_block_prefix"].sub(r"\n\1", content)
|
||||
# Ensure newline after ```lang
|
||||
content = self._PATTERNS["code_block_suffix"].sub(r"\1\n\2", content)
|
||||
return content
|
||||
|
||||
def _fix_latex_formulas(self, content: str) -> str:
|
||||
"""Normalize LaTeX formulas: \[ -> $$ (block), \( -> $ (inline)"""
|
||||
content = self._PATTERNS["latex_bracket_block"].sub(r"$$\1$$", content)
|
||||
content = self._PATTERNS["latex_paren_inline"].sub(r"$\1$", content)
|
||||
return content
|
||||
|
||||
def _fix_list_formatting(self, content: str) -> str:
|
||||
"""Fix missing newlines in lists (e.g., 'text1. item' -> 'text\\n1. item')"""
|
||||
return self._PATTERNS["list_item"].sub(r"\1\n\2", content)
|
||||
|
||||
def _fix_unclosed_code_blocks(self, content: str) -> str:
|
||||
"""Auto-close unclosed code blocks"""
|
||||
if content.count("```") % 2 != 0:
|
||||
content += "\n```"
|
||||
return content
|
||||
|
||||
def _fix_fullwidth_symbols_in_code(self, content: str) -> str:
|
||||
"""Convert full-width symbols to half-width inside code blocks"""
|
||||
FULLWIDTH_MAP = {
|
||||
",": ",",
|
||||
"。": ".",
|
||||
"(": "(",
|
||||
")": ")",
|
||||
"【": "[",
|
||||
"】": "]",
|
||||
";": ";",
|
||||
":": ":",
|
||||
"?": "?",
|
||||
"!": "!",
|
||||
"“": '"',
|
||||
"”": '"',
|
||||
"‘": "'",
|
||||
"’": "'",
|
||||
}
|
||||
|
||||
parts = content.split("```")
|
||||
# Code block content is at odd indices: 1, 3, 5...
|
||||
for i in range(1, len(parts), 2):
|
||||
for full, half in FULLWIDTH_MAP.items():
|
||||
parts[i] = parts[i].replace(full, half)
|
||||
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_mermaid_syntax(self, content: str) -> str:
|
||||
"""修复常见的 Mermaid 语法错误,同时保留节点形状"""
|
||||
|
||||
def replacer(match):
|
||||
# Group 1 is Quoted String (if matched)
|
||||
if match.group(1):
|
||||
return match.group(1)
|
||||
|
||||
# Group 2 is ID
|
||||
id_str = match.group(2)
|
||||
|
||||
# Find matching shape group
|
||||
groups = match.groups()
|
||||
citation = groups[-1] or "" # Last group is citation
|
||||
|
||||
# Iterate over shape groups (excluding the last citation group)
|
||||
for i in range(2, len(groups) - 1, 3):
|
||||
if groups[i] is not None:
|
||||
open_char = groups[i]
|
||||
content = groups[i + 1]
|
||||
close_char = groups[i + 2]
|
||||
|
||||
# Append citation to content if present
|
||||
if citation:
|
||||
content += citation
|
||||
|
||||
# 如果内容包含引号,进行转义
|
||||
content = content.replace('"', '\\"')
|
||||
|
||||
return f'{id_str}{open_char}"{content}"{close_char}'
|
||||
|
||||
return match.group(0)
|
||||
|
||||
parts = content.split("```")
|
||||
for i in range(1, len(parts), 2):
|
||||
# Check if it's a mermaid block
|
||||
lang_line = parts[i].split("\n", 1)[0].strip().lower()
|
||||
if "mermaid" in lang_line:
|
||||
# Protect edge labels (text between link start and arrow) from being modified
|
||||
# by temporarily replacing them with placeholders.
|
||||
# Covers all Mermaid link types:
|
||||
# - Solid line: A -- text --> B, A -- text --o B, A -- text --x B
|
||||
# - Dotted line: A -. text .-> B, A -. text .-o B
|
||||
# - Thick line: A == text ==> B, A == text ==o B
|
||||
# - No arrow: A -- text --- B
|
||||
edge_labels = []
|
||||
|
||||
def protect_edge_label(m):
|
||||
start = m.group(1) # Link start: --, -., or ==
|
||||
label = m.group(2) # Text content
|
||||
arrow = m.group(3) # Arrow/end pattern
|
||||
edge_labels.append((start, label, arrow))
|
||||
return f"___EDGE_LABEL_{len(edge_labels)-1}___"
|
||||
|
||||
# Comprehensive edge label pattern for all Mermaid link types
|
||||
edge_label_pattern = (
|
||||
r"(--|-\.|\=\=)\s+(.+?)\s+(--+[>ox]?|--+\|>|\.-[>ox]?|=+[>ox]?)"
|
||||
)
|
||||
protected = re.sub(edge_label_pattern, protect_edge_label, parts[i])
|
||||
|
||||
# Apply the comprehensive regex fix to protected content
|
||||
fixed = self._PATTERNS["mermaid_node"].sub(replacer, protected)
|
||||
|
||||
# Restore edge labels
|
||||
for idx, (start, label, arrow) in enumerate(edge_labels):
|
||||
fixed = fixed.replace(
|
||||
f"___EDGE_LABEL_{idx}___", f"{start} {label} {arrow}"
|
||||
)
|
||||
|
||||
parts[i] = fixed
|
||||
|
||||
# Auto-close subgraphs
|
||||
# Count 'subgraph' and 'end' (case-insensitive)
|
||||
# We use a simple regex to avoid matching words inside labels (though labels are now quoted, so it's safer)
|
||||
# But for simplicity and speed, we just count occurrences in the whole block.
|
||||
# A more robust way would be to strip quoted strings first, but that's expensive.
|
||||
# Given we just quoted everything, let's try to count keywords outside quotes?
|
||||
# Actually, since we just normalized nodes, most text is in quotes.
|
||||
# Let's just do a simple count. It's a heuristic fix.
|
||||
subgraph_count = len(
|
||||
re.findall(r"\bsubgraph\b", parts[i], re.IGNORECASE)
|
||||
)
|
||||
end_count = len(re.findall(r"\bend\b", parts[i], re.IGNORECASE))
|
||||
|
||||
if subgraph_count > end_count:
|
||||
missing_ends = subgraph_count - end_count
|
||||
parts[i] = parts[i].rstrip() + ("\n end" * missing_ends) + "\n"
|
||||
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_headings(self, content: str) -> str:
|
||||
"""Fix missing space in headings: #Heading -> # Heading"""
|
||||
# We only fix if it's not inside a code block.
|
||||
# But splitting by code block is expensive.
|
||||
# Given headings usually don't appear inside code blocks without space in valid code (except comments),
|
||||
# we might risk false positives in comments like `#TODO`.
|
||||
# To be safe, let's split by code blocks.
|
||||
|
||||
parts = content.split("```")
|
||||
for i in range(0, len(parts), 2): # Even indices are markdown text
|
||||
parts[i] = self._PATTERNS["heading_space"].sub(r"\1 \2", parts[i])
|
||||
return "```".join(parts)
|
||||
|
||||
def _fix_tables(self, content: str) -> str:
|
||||
"""Fix tables missing closing pipe"""
|
||||
parts = content.split("```")
|
||||
for i in range(0, len(parts), 2):
|
||||
parts[i] = self._PATTERNS["table_pipe"].sub(r"\1|", parts[i])
|
||||
return "```".join(parts)
|
||||
|
||||
def _cleanup_xml_tags(self, content: str) -> str:
|
||||
"""Remove leftover XML tags"""
|
||||
return self._PATTERNS["xml_artifacts"].sub("", content)
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
priority: int = Field(
|
||||
default=50,
|
||||
description="优先级。数值越高运行越晚 (建议在其他过滤器之后运行)。",
|
||||
)
|
||||
enable_escape_fix: bool = Field(
|
||||
default=True, description="修复过度的转义字符 (\\n, \\t 等)"
|
||||
)
|
||||
enable_thought_tag_fix: bool = Field(
|
||||
default=True, description="规范化思维链标签 (<think> -> <thought>)"
|
||||
)
|
||||
enable_code_block_fix: bool = Field(
|
||||
default=True,
|
||||
description="修复代码块格式 (缩进、换行)",
|
||||
)
|
||||
enable_latex_fix: bool = Field(
|
||||
default=True, description="规范化 LaTeX 公式 (\\[ -> $$, \\( -> $)"
|
||||
)
|
||||
enable_list_fix: bool = Field(
|
||||
default=False, description="修复列表项换行 (实验性)"
|
||||
)
|
||||
enable_unclosed_block_fix: bool = Field(
|
||||
default=True, description="自动闭合未闭合的代码块"
|
||||
)
|
||||
enable_fullwidth_symbol_fix: bool = Field(
|
||||
default=False, description="修复代码块中的全角符号"
|
||||
)
|
||||
enable_mermaid_fix: bool = Field(
|
||||
default=True,
|
||||
description="修复常见的 Mermaid 语法错误 (如未加引号的标签)",
|
||||
)
|
||||
enable_heading_fix: bool = Field(
|
||||
default=True,
|
||||
description="修复标题中缺失的空格 (#Header -> # Header)",
|
||||
)
|
||||
enable_table_fix: bool = Field(
|
||||
default=True, description="修复表格中缺失的闭合管道符"
|
||||
)
|
||||
enable_xml_tag_cleanup: bool = Field(
|
||||
default=True, description="清理残留的 XML 标签"
|
||||
)
|
||||
show_status: bool = Field(default=True, description="应用修复时显示状态通知")
|
||||
show_debug_log: bool = Field(
|
||||
default=True, description="在浏览器控制台打印调试日志 (F12)"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
def _get_chat_context(
|
||||
self, body: dict, __metadata__: Optional[dict] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
统一提取聊天上下文信息 (chat_id, message_id)。
|
||||
优先从 body 中提取,其次从 metadata 中提取。
|
||||
"""
|
||||
chat_id = ""
|
||||
message_id = ""
|
||||
|
||||
# 1. 尝试从 body 获取
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id", "")
|
||||
message_id = body.get("id", "") # message_id 在 body 中通常是 id
|
||||
|
||||
# 再次检查 body.metadata
|
||||
if not chat_id or not message_id:
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
if not chat_id:
|
||||
chat_id = body_metadata.get("chat_id", "")
|
||||
if not message_id:
|
||||
message_id = body_metadata.get("message_id", "")
|
||||
|
||||
# 2. 尝试从 __metadata__ 获取 (作为补充)
|
||||
if __metadata__ and isinstance(__metadata__, dict):
|
||||
if not chat_id:
|
||||
chat_id = __metadata__.get("chat_id", "")
|
||||
if not message_id:
|
||||
message_id = __metadata__.get("message_id", "")
|
||||
|
||||
return {
|
||||
"chat_id": str(chat_id).strip(),
|
||||
"message_id": str(message_id).strip(),
|
||||
}
|
||||
|
||||
def _contains_html(self, content: str) -> bool:
|
||||
"""Check if content contains HTML tags (to avoid breaking HTML output)"""
|
||||
# Removed common Mermaid-compatible tags like br, b, i, strong, em, span
|
||||
pattern = r"<\s*/?\s*(?:html|head|body|div|p|hr|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|img|a|code|pre|blockquote|h[1-6]|script|style|form|input|button|label|select|option|iframe|link|meta|title)\b"
|
||||
return bool(re.search(pattern, content, re.IGNORECASE))
|
||||
|
||||
async def _emit_status(self, __event_emitter__, applied_fixes: List[str]):
|
||||
"""Emit status notification"""
|
||||
if not self.valves.show_status or not applied_fixes:
|
||||
return
|
||||
|
||||
description = "✓ Markdown 已修复"
|
||||
if applied_fixes:
|
||||
# Translate fix names for status display
|
||||
fix_map = {
|
||||
"Fix Escape Chars": "转义字符",
|
||||
"Normalize Thought Tags": "思维标签",
|
||||
"Fix Code Blocks": "代码块",
|
||||
"Normalize LaTeX": "LaTeX公式",
|
||||
"Fix List Format": "列表格式",
|
||||
"Close Code Blocks": "闭合代码块",
|
||||
"Fix Full-width Symbols": "全角符号",
|
||||
"Fix Mermaid Syntax": "Mermaid语法",
|
||||
"Fix Headings": "标题格式",
|
||||
"Fix Tables": "表格格式",
|
||||
"Cleanup XML Tags": "XML清理",
|
||||
"Custom Cleaner": "自定义清理",
|
||||
}
|
||||
translated_fixes = [fix_map.get(fix, fix) for fix in applied_fixes]
|
||||
description += f": {', '.join(translated_fixes)}"
|
||||
|
||||
try:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": description,
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error emitting status: {e}")
|
||||
|
||||
async def _emit_debug_log(
|
||||
self,
|
||||
__event_call__,
|
||||
applied_fixes: List[str],
|
||||
original: str,
|
||||
normalized: str,
|
||||
chat_id: str = "",
|
||||
):
|
||||
"""Emit debug log to browser console via JS execution"""
|
||||
if not self.valves.show_debug_log or not __event_call__:
|
||||
return
|
||||
|
||||
try:
|
||||
# Construct JS code
|
||||
js_code = f"""
|
||||
(async function() {{
|
||||
console.group("🛠️ Markdown Normalizer Debug");
|
||||
console.log("Chat ID:", {json.dumps(chat_id)});
|
||||
console.log("Applied Fixes:", {json.dumps(applied_fixes, ensure_ascii=False)});
|
||||
console.log("Original Content:", {json.dumps(original, ensure_ascii=False)});
|
||||
console.log("Normalized Content:", {json.dumps(normalized, ensure_ascii=False)});
|
||||
console.groupEnd();
|
||||
}})();
|
||||
"""
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error emitting debug log: {e}")
|
||||
|
||||
async def outlet(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[dict] = None,
|
||||
__event_emitter__=None,
|
||||
__event_call__=None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Process the response body to normalize Markdown content.
|
||||
"""
|
||||
if "messages" in body and body["messages"]:
|
||||
last = body["messages"][-1]
|
||||
content = last.get("content", "") or ""
|
||||
|
||||
if last.get("role") == "assistant" and isinstance(content, str):
|
||||
# Skip if content looks like HTML to avoid breaking it
|
||||
if self._contains_html(content):
|
||||
return body
|
||||
|
||||
# Configure normalizer based on valves
|
||||
config = NormalizerConfig(
|
||||
enable_escape_fix=self.valves.enable_escape_fix,
|
||||
enable_thought_tag_fix=self.valves.enable_thought_tag_fix,
|
||||
enable_code_block_fix=self.valves.enable_code_block_fix,
|
||||
enable_latex_fix=self.valves.enable_latex_fix,
|
||||
enable_list_fix=self.valves.enable_list_fix,
|
||||
enable_unclosed_block_fix=self.valves.enable_unclosed_block_fix,
|
||||
enable_fullwidth_symbol_fix=self.valves.enable_fullwidth_symbol_fix,
|
||||
enable_mermaid_fix=self.valves.enable_mermaid_fix,
|
||||
enable_heading_fix=self.valves.enable_heading_fix,
|
||||
enable_table_fix=self.valves.enable_table_fix,
|
||||
enable_xml_tag_cleanup=self.valves.enable_xml_tag_cleanup,
|
||||
)
|
||||
|
||||
normalizer = ContentNormalizer(config)
|
||||
|
||||
# Execute normalization
|
||||
new_content = normalizer.normalize(content)
|
||||
|
||||
# Update content if changed
|
||||
if new_content != content:
|
||||
last["content"] = new_content
|
||||
|
||||
# Emit status if enabled
|
||||
if __event_emitter__:
|
||||
await self._emit_status(
|
||||
__event_emitter__, normalizer.applied_fixes
|
||||
)
|
||||
chat_ctx = self._get_chat_context(body, __metadata__)
|
||||
await self._emit_debug_log(
|
||||
__event_call__,
|
||||
normalizer.applied_fixes,
|
||||
content,
|
||||
new_content,
|
||||
chat_id=chat_ctx["chat_id"],
|
||||
)
|
||||
|
||||
return body
|
||||
191
plugins/filters/markdown_normalizer/test_markdown_normalizer.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the current directory to sys.path to import the module
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(current_dir)
|
||||
|
||||
from markdown_normalizer import ContentNormalizer, NormalizerConfig
|
||||
|
||||
|
||||
class TestMarkdownNormalizer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = NormalizerConfig(
|
||||
enable_escape_fix=True,
|
||||
enable_thought_tag_fix=True,
|
||||
enable_code_block_fix=True,
|
||||
enable_latex_fix=True,
|
||||
enable_list_fix=True,
|
||||
enable_unclosed_block_fix=True,
|
||||
enable_fullwidth_symbol_fix=True,
|
||||
enable_mermaid_fix=True,
|
||||
enable_xml_tag_cleanup=True,
|
||||
)
|
||||
self.normalizer = ContentNormalizer(self.config)
|
||||
|
||||
def test_escape_fix(self):
|
||||
input_text = "Line 1\\nLine 2\\tTabbed"
|
||||
expected = "Line 1\nLine 2\tTabbed"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
def test_thought_tag_fix(self):
|
||||
# Case 1: Standard tag spacing
|
||||
input_text = "Thinking...</thought>Result"
|
||||
expected = "Thinking...</thought>\n\nResult"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
# Case 2: Tag standardization (<think> -> <thought>)
|
||||
input_text_deepseek = "<think>Deep thinking...</think>Result"
|
||||
expected_deepseek = "<thought>Deep thinking...</thought>\n\nResult"
|
||||
self.assertEqual(
|
||||
self.normalizer.normalize(input_text_deepseek), expected_deepseek
|
||||
)
|
||||
|
||||
def test_code_block_fix(self):
|
||||
# Case 1: Indentation
|
||||
self.assertEqual(self.normalizer._fix_code_blocks(" ```python"), "```python")
|
||||
|
||||
# Case 2: Prefix (newline before block)
|
||||
self.assertEqual(
|
||||
self.normalizer._fix_code_blocks("Text```python"), "Text\n```python"
|
||||
)
|
||||
|
||||
# Case 3: Suffix (newline after lang)
|
||||
self.assertEqual(
|
||||
self.normalizer._fix_code_blocks("```python print('hi')"),
|
||||
"```python\nprint('hi')",
|
||||
)
|
||||
|
||||
def test_latex_fix(self):
|
||||
input_text = "Block: \\[ x^2 \\] Inline: \\( E=mc^2 \\)"
|
||||
expected = "Block: $$ x^2 $$ Inline: $ E=mc^2 $"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
def test_list_fix(self):
|
||||
input_text = "Item 1. First\nItem 2. Second" # This is fine
|
||||
input_text_bad = "Header1. Item 1"
|
||||
expected = "Header\n1. Item 1"
|
||||
self.assertEqual(self.normalizer.normalize(input_text_bad), expected)
|
||||
|
||||
def test_unclosed_code_block_fix(self):
|
||||
input_text = "```python\nprint('hello')"
|
||||
expected = "```python\nprint('hello')\n```"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
def test_fullwidth_symbol_fix(self):
|
||||
input_text = "Outside:Fullwidth ```python\nprint('hello')```"
|
||||
expected = "Outside:Fullwidth \n```python\nprint('hello')\n```"
|
||||
|
||||
normalized = self.normalizer.normalize(input_text)
|
||||
self.assertIn("print('hello')", normalized)
|
||||
self.assertIn("Outside:Fullwidth", normalized)
|
||||
self.assertNotIn("(", normalized)
|
||||
self.assertNotIn(")", normalized)
|
||||
|
||||
def test_mermaid_fix(self):
|
||||
# Test Mermaid syntax fix for unquoted labels
|
||||
# Note: Regex-based fix handles mixed brackets well (e.g. [] inside ())
|
||||
# but cannot perfectly handle same-type nesting (e.g. {} inside {}) without a parser.
|
||||
input_text = """
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Label with (parens)] --> B(Label with [brackets])
|
||||
C{Label with [brackets]}
|
||||
```
|
||||
"""
|
||||
expected_snippet = """
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Label with (parens)"] --> B("Label with [brackets]")
|
||||
C{"Label with [brackets]"}
|
||||
```
|
||||
"""
|
||||
normalized = self.normalizer.normalize(input_text)
|
||||
|
||||
self.assertIn('A["Label with (parens)"]', normalized)
|
||||
self.assertIn('B("Label with [brackets]")', normalized)
|
||||
self.assertIn('C{"Label with [brackets]"}', normalized)
|
||||
|
||||
def test_mermaid_shapes_regression(self):
|
||||
# Regression test for "reverse optimization" where ((...)) was broken into ("(...)")
|
||||
input_text = """
|
||||
```mermaid
|
||||
graph TD
|
||||
Start((开始)) --> Input[[输入]]
|
||||
Input --> Verify{验证}
|
||||
Verify --> End(((结束)))
|
||||
```
|
||||
"""
|
||||
expected_snippet = """
|
||||
```mermaid
|
||||
graph TD
|
||||
Start(("开始")) --> Input[["输入"]]
|
||||
Input --> Verify{"验证"}
|
||||
Verify --> End((("结束")))
|
||||
```
|
||||
"""
|
||||
normalized = self.normalizer.normalize(input_text)
|
||||
self.assertIn('Start(("开始"))', normalized)
|
||||
self.assertIn('Input[["输入"]]', normalized)
|
||||
self.assertIn('Verify{"验证"}', normalized)
|
||||
self.assertIn('End((("结束")))', normalized)
|
||||
|
||||
def test_xml_cleanup(self):
|
||||
input_text = "Some text <antArtifact>hidden</antArtifact> visible"
|
||||
expected = "Some text hidden visible"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
def test_heading_fix(self):
|
||||
input_text = "#Heading 1\n##Heading 2\n### Valid Heading"
|
||||
expected = "# Heading 1\n## Heading 2\n### Valid Heading"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
def test_table_fix(self):
|
||||
input_text = "| Col 1 | Col 2\n| Val 1 | Val 2"
|
||||
expected = "| Col 1 | Col 2|\n| Val 1 | Val 2|"
|
||||
self.assertEqual(self.normalizer.normalize(input_text), expected)
|
||||
|
||||
def test_mermaid_subgraph_autoclose(self):
|
||||
"""Test auto-closing of Mermaid subgraphs"""
|
||||
# Case 1: Simple unclosed subgraph
|
||||
original = """
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph One
|
||||
A --> B
|
||||
```
|
||||
"""
|
||||
expected = """
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph One
|
||||
A --> B
|
||||
end
|
||||
```
|
||||
"""
|
||||
# Note: The normalizer might add quotes to A and B if they match the node pattern,
|
||||
# but here they are simple IDs. However, our regex is strict about shapes.
|
||||
# Simple IDs like A and B are NOT matched by our mermaid_node regex because it requires a shape delimiter.
|
||||
# So A and B remain A and B.
|
||||
|
||||
normalized = self.normalizer.normalize(original)
|
||||
# We need to be careful about whitespace in comparison
|
||||
self.assertIn("end", normalized)
|
||||
self.assertEqual(normalized.strip(), expected.strip())
|
||||
|
||||
# Case 2: Nested unclosed subgraphs
|
||||
original_nested = """
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Outer
|
||||
subgraph Inner
|
||||
C --> D
|
||||
```
|
||||
"""
|
||||
normalized_nested = self.normalizer.normalize(original_nested)
|
||||
self.assertEqual(normalized_nested.count("end"), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
133
scripts/download_plugin_images.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Download plugin images from OpenWebUI Community
|
||||
下载远程插件图片到本地目录
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Add current directory to path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from openwebui_community_client import get_client
|
||||
|
||||
|
||||
def find_local_plugin_by_id(plugins_dir: str, post_id: str) -> str | None:
|
||||
"""根据 post_id 查找本地插件文件"""
|
||||
for root, _, files in os.walk(plugins_dir):
|
||||
for file in files:
|
||||
if file.endswith(".py"):
|
||||
file_path = os.path.join(root, file)
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read(2000)
|
||||
|
||||
id_match = re.search(
|
||||
r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content
|
||||
)
|
||||
if id_match and id_match.group(1).strip() == post_id:
|
||||
return file_path
|
||||
return None
|
||||
|
||||
|
||||
def download_image(url: str, save_path: str) -> bool:
|
||||
"""下载图片"""
|
||||
try:
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" Error downloading: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_image_extension(url: str) -> str:
|
||||
"""从 URL 获取图片扩展名"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in [".png", ".jpg", ".jpeg", ".gif", ".webp"]:
|
||||
return ext
|
||||
return ".png" # 默认
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
plugins_dir = os.path.join(base_dir, "plugins")
|
||||
|
||||
print("Fetching remote posts from OpenWebUI Community...")
|
||||
posts = client.get_all_posts()
|
||||
print(f"Found {len(posts)} remote posts.\n")
|
||||
|
||||
downloaded = 0
|
||||
skipped = 0
|
||||
not_found = 0
|
||||
|
||||
for post in posts:
|
||||
post_id = post.get("id")
|
||||
title = post.get("title", "Unknown")
|
||||
media = post.get("media", [])
|
||||
|
||||
if not media:
|
||||
continue
|
||||
|
||||
# 只取第一张图片
|
||||
first_media = media[0] if isinstance(media, list) else media
|
||||
|
||||
# 处理字典格式 {'url': '...', 'type': 'image'}
|
||||
if isinstance(first_media, dict):
|
||||
image_url = first_media.get("url")
|
||||
else:
|
||||
image_url = first_media
|
||||
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
print(f"Processing: {title}")
|
||||
print(f" Image URL: {image_url}")
|
||||
|
||||
# 查找对应的本地插件
|
||||
local_plugin = find_local_plugin_by_id(plugins_dir, post_id)
|
||||
if not local_plugin:
|
||||
print(f" ⚠️ No local plugin found for ID: {post_id}")
|
||||
not_found += 1
|
||||
continue
|
||||
|
||||
# 确定保存路径
|
||||
plugin_dir = os.path.dirname(local_plugin)
|
||||
plugin_name = os.path.splitext(os.path.basename(local_plugin))[0]
|
||||
ext = get_image_extension(image_url)
|
||||
save_path = os.path.join(plugin_dir, plugin_name + ext)
|
||||
|
||||
# 检查是否已存在
|
||||
if os.path.exists(save_path):
|
||||
print(f" ⏭️ Image already exists: {os.path.basename(save_path)}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 下载
|
||||
print(f" Downloading to: {save_path}")
|
||||
if download_image(image_url, save_path):
|
||||
print(f" ✅ Downloaded: {os.path.basename(save_path)}")
|
||||
downloaded += 1
|
||||
else:
|
||||
print(f" ❌ Failed to download")
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(
|
||||
f"Finished: {downloaded} downloaded, {skipped} skipped, {not_found} not found locally"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,8 @@
|
||||
"""
|
||||
Fetch remote plugin versions from OpenWebUI Community
|
||||
获取远程插件版本信息
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -5,22 +10,17 @@ import sys
|
||||
# Add current directory to path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
try:
|
||||
from openwebui_stats import OpenWebUIStats
|
||||
except ImportError:
|
||||
print("Error: openwebui_stats.py not found.")
|
||||
sys.exit(1)
|
||||
from openwebui_community_client import get_client
|
||||
|
||||
|
||||
def main():
|
||||
# Try to get token from env
|
||||
token = os.environ.get("OPENWEBUI_API_KEY")
|
||||
if not token:
|
||||
print("Error: OPENWEBUI_API_KEY environment variable not set.")
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Fetching remote plugins from OpenWebUI...")
|
||||
client = OpenWebUIStats(token)
|
||||
print("Fetching remote plugins from OpenWebUI Community...")
|
||||
try:
|
||||
posts = client.get_all_posts()
|
||||
except Exception as e:
|
||||
@@ -29,9 +29,6 @@ def main():
|
||||
|
||||
formatted_plugins = []
|
||||
for post in posts:
|
||||
# Save the full raw post object to ensure we have "compliant update json data"
|
||||
# We inject a 'type' field just for the comparison script to know it's remote,
|
||||
# but otherwise keep the structure identical to the API response.
|
||||
post["type"] = "remote_plugin"
|
||||
formatted_plugins.append(post)
|
||||
|
||||
|
||||
727
scripts/openwebui_community_client.py
Normal file
@@ -0,0 +1,727 @@
|
||||
"""
|
||||
OpenWebUI Community Client
|
||||
统一封装所有与 OpenWebUI 官方社区 (openwebui.com) 的 API 交互。
|
||||
|
||||
功能:
|
||||
- 获取用户发布的插件/帖子
|
||||
- 更新插件内容和元数据
|
||||
- 版本比较
|
||||
- 同步插件 ID
|
||||
|
||||
使用方法:
|
||||
from openwebui_community_client import OpenWebUICommunityClient
|
||||
|
||||
client = OpenWebUICommunityClient(api_key="your_api_key")
|
||||
posts = client.get_all_posts()
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, List, Any, Tuple
|
||||
|
||||
# 北京时区 (UTC+8)
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class OpenWebUICommunityClient:
|
||||
"""OpenWebUI 官方社区 API 客户端"""
|
||||
|
||||
BASE_URL = "https://api.openwebui.com/api/v1"
|
||||
|
||||
def __init__(self, api_key: str, user_id: Optional[str] = None):
|
||||
"""
|
||||
初始化客户端
|
||||
|
||||
Args:
|
||||
api_key: OpenWebUI API Key (JWT Token)
|
||||
user_id: 用户 ID,如果为 None 则从 token 中解析
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.user_id = user_id or self._parse_user_id_from_token(api_key)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# 如果没有 user_id,尝试通过 API 获取
|
||||
if not self.user_id:
|
||||
self.user_id = self._get_user_id_from_api()
|
||||
|
||||
def _parse_user_id_from_token(self, token: str) -> Optional[str]:
|
||||
"""从 JWT Token 中解析用户 ID"""
|
||||
# sk- 开头的是 API Key,无法解析用户 ID
|
||||
if token.startswith("sk-"):
|
||||
return None
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) >= 2:
|
||||
payload = parts[1]
|
||||
# 添加 padding
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
data = json.loads(decoded)
|
||||
return data.get("id") or data.get("sub")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_user_id_from_api(self) -> Optional[str]:
|
||||
"""通过 API 获取当前用户 ID"""
|
||||
try:
|
||||
url = f"{self.BASE_URL}/auths/"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("id")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ========== 帖子/插件获取 ==========
|
||||
|
||||
def get_user_posts(self, sort: str = "new", page: int = 1) -> List[Dict]:
|
||||
"""
|
||||
获取用户发布的帖子列表
|
||||
|
||||
Args:
|
||||
sort: 排序方式 (new/top/hot)
|
||||
page: 页码
|
||||
|
||||
Returns:
|
||||
帖子列表
|
||||
"""
|
||||
url = f"{self.BASE_URL}/posts/users/{self.user_id}?sort={sort}&page={page}"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_all_posts(self, sort: str = "new") -> List[Dict]:
|
||||
"""获取所有帖子(自动分页)"""
|
||||
all_posts = []
|
||||
page = 1
|
||||
while True:
|
||||
posts = self.get_user_posts(sort=sort, page=page)
|
||||
if not posts:
|
||||
break
|
||||
all_posts.extend(posts)
|
||||
page += 1
|
||||
return all_posts
|
||||
|
||||
def get_post(self, post_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取单个帖子详情
|
||||
|
||||
Args:
|
||||
post_id: 帖子 ID
|
||||
|
||||
Returns:
|
||||
帖子数据,如果不存在返回 None
|
||||
"""
|
||||
try:
|
||||
url = f"{self.BASE_URL}/posts/{post_id}"
|
||||
response = requests.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
# ========== 帖子/插件创建 ==========
|
||||
|
||||
def create_post(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
post_type: str = "function",
|
||||
data: Optional[Dict] = None,
|
||||
media: Optional[List[str]] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
创建新帖子
|
||||
|
||||
Args:
|
||||
title: 帖子标题
|
||||
content: 帖子内容(README/描述)
|
||||
post_type: 帖子类型 (function/tool/filter/pipeline)
|
||||
data: 插件数据结构
|
||||
media: 图片 URL 列表
|
||||
|
||||
Returns:
|
||||
创建成功返回帖子数据,失败返回 None
|
||||
"""
|
||||
try:
|
||||
url = f"{self.BASE_URL}/posts/create"
|
||||
|
||||
# 将字符串 URL 转换为字典格式 (API 要求)
|
||||
media_list = []
|
||||
if media:
|
||||
for item in media:
|
||||
if isinstance(item, str):
|
||||
media_list.append({"url": item})
|
||||
elif isinstance(item, dict):
|
||||
media_list.append(item)
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"type": post_type,
|
||||
"data": data or {},
|
||||
"media": media_list,
|
||||
}
|
||||
print(f" [DEBUG] Payload keys: {list(payload.keys())}")
|
||||
print(
|
||||
f" [DEBUG] media format: {media_list[:1] if media_list else 'empty'}"
|
||||
)
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
if response.status_code != 200:
|
||||
print(f" [DEBUG] Response status: {response.status_code}")
|
||||
print(f" [DEBUG] Response body: {response.text[:500]}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f" Error creating post: {e}")
|
||||
return None
|
||||
|
||||
def create_plugin(
|
||||
self,
|
||||
title: str,
|
||||
source_code: str,
|
||||
readme_content: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
media_urls: Optional[List[str]] = None,
|
||||
plugin_type: str = "action",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
创建新插件帖子
|
||||
|
||||
Args:
|
||||
title: 插件标题
|
||||
source_code: 插件源代码
|
||||
readme_content: README 内容
|
||||
metadata: 插件元数据
|
||||
media_urls: 图片 URL 列表
|
||||
plugin_type: 插件类型 (action/filter/pipe)
|
||||
|
||||
Returns:
|
||||
创建成功返回帖子 ID,失败返回 None
|
||||
"""
|
||||
# 构建 function 数据结构
|
||||
function_data = {
|
||||
"id": "", # 服务器会生成
|
||||
"name": title,
|
||||
"type": plugin_type,
|
||||
"content": source_code,
|
||||
"meta": {
|
||||
"description": metadata.get("description", "") if metadata else "",
|
||||
"manifest": metadata or {},
|
||||
},
|
||||
}
|
||||
|
||||
data = {"function": function_data}
|
||||
|
||||
result = self.create_post(
|
||||
title=title,
|
||||
content=(
|
||||
readme_content or metadata.get("description", "") if metadata else ""
|
||||
),
|
||||
post_type="function",
|
||||
data=data,
|
||||
media=media_urls,
|
||||
)
|
||||
|
||||
if result:
|
||||
return result.get("id")
|
||||
return None
|
||||
|
||||
# ========== 帖子/插件更新 ==========
|
||||
|
||||
def update_post(self, post_id: str, post_data: Dict) -> bool:
|
||||
"""
|
||||
更新帖子
|
||||
|
||||
Args:
|
||||
post_id: 帖子 ID
|
||||
post_data: 完整的帖子数据
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
url = f"{self.BASE_URL}/posts/{post_id}/update"
|
||||
|
||||
# 仅发送允许更新的字段,避免 422 错误
|
||||
allowed_keys = ["title", "content", "type", "data", "media"]
|
||||
payload = {k: v for k, v in post_data.items() if k in allowed_keys}
|
||||
|
||||
response = requests.post(url, headers=self.headers, json=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
error_detail = response.json()
|
||||
print(
|
||||
f" Error: Update failed ({response.status_code}): {json.dumps(error_detail, indent=2)}"
|
||||
)
|
||||
except Exception:
|
||||
print(
|
||||
f" Error: Update failed ({response.status_code}): {response.text[:500]}"
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
def update_plugin(
|
||||
self,
|
||||
post_id: str,
|
||||
source_code: str,
|
||||
readme_content: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
media_urls: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
更新插件(代码 + README + 元数据 + 图片)
|
||||
|
||||
Args:
|
||||
post_id: 帖子 ID
|
||||
source_code: 插件源代码
|
||||
readme_content: README 内容(用于社区页面展示)
|
||||
metadata: 插件元数据(title, version, description 等)
|
||||
media_urls: 图片 URL 列表
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
post_data = self.get_post(post_id)
|
||||
if not post_data:
|
||||
return False
|
||||
|
||||
# 严格重建 data 结构,避免包含只读字段(如 data.function.id)
|
||||
current_function = post_data.get("data", {}).get("function", {})
|
||||
|
||||
# 过滤 metadata,移除 openwebui_id 等系统字段
|
||||
clean_metadata = {
|
||||
k: v
|
||||
for k, v in (metadata or {}).items()
|
||||
if k not in ["openwebui_id", "post_id"]
|
||||
}
|
||||
|
||||
function_data = {
|
||||
"id": current_function.get("id", ""),
|
||||
"name": metadata.get("title", current_function.get("name", "Plugin")),
|
||||
"type": current_function.get("type", "action"),
|
||||
"content": source_code,
|
||||
"meta": {
|
||||
"description": metadata.get(
|
||||
"description",
|
||||
current_function.get("meta", {}).get("description", ""),
|
||||
),
|
||||
"manifest": clean_metadata,
|
||||
},
|
||||
}
|
||||
|
||||
post_data["data"] = {"function": function_data}
|
||||
post_data["type"] = "function"
|
||||
|
||||
# 更新 README(社区页面展示内容)
|
||||
if readme_content:
|
||||
post_data["content"] = readme_content
|
||||
|
||||
# 更新标题
|
||||
if metadata and "title" in metadata:
|
||||
post_data["title"] = metadata["title"]
|
||||
|
||||
# 更新图片
|
||||
if media_urls:
|
||||
# 将字符串 URL 转换为字典格式 (API 要求)
|
||||
media_list = []
|
||||
for item in media_urls:
|
||||
if isinstance(item, str):
|
||||
media_list.append({"url": item})
|
||||
elif isinstance(item, dict):
|
||||
media_list.append(item)
|
||||
post_data["media"] = media_list
|
||||
else:
|
||||
# 如果没有新图片,保留原有的(如果有)
|
||||
pass
|
||||
|
||||
return self.update_post(post_id, post_data)
|
||||
|
||||
# ========== 图片上传 ==========
|
||||
|
||||
def upload_image(self, file_path: str) -> Optional[str]:
|
||||
"""
|
||||
上传图片到 OpenWebUI 社区
|
||||
|
||||
Args:
|
||||
file_path: 图片文件路径
|
||||
|
||||
Returns:
|
||||
上传成功后的图片 URL,失败返回 None
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
# 获取文件信息
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# 根据文件扩展名确定 MIME 类型
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
mime_types = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
content_type = mime_types.get(ext, "application/octet-stream")
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": (filename, f, content_type)}
|
||||
# 上传时不使用 JSON Content-Type
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}/files/",
|
||||
headers=headers,
|
||||
files=files,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# 返回图片 URL
|
||||
return result.get("url")
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to upload image: {e}")
|
||||
return None
|
||||
|
||||
# ========== 版本比较 ==========
|
||||
|
||||
def get_remote_version(self, post_id: str) -> Optional[str]:
|
||||
"""
|
||||
获取远程插件版本
|
||||
|
||||
Args:
|
||||
post_id: 帖子 ID
|
||||
|
||||
Returns:
|
||||
版本号,如果不存在返回 None
|
||||
"""
|
||||
post_data = self.get_post(post_id)
|
||||
if not post_data:
|
||||
return None
|
||||
return (
|
||||
post_data.get("data", {})
|
||||
.get("function", {})
|
||||
.get("meta", {})
|
||||
.get("manifest", {})
|
||||
.get("version")
|
||||
)
|
||||
|
||||
def version_needs_update(self, post_id: str, local_version: str) -> bool:
|
||||
"""
|
||||
检查是否需要更新
|
||||
|
||||
Args:
|
||||
post_id: 帖子 ID
|
||||
local_version: 本地版本号
|
||||
|
||||
Returns:
|
||||
如果本地版本与远程不同,返回 True
|
||||
"""
|
||||
remote_version = self.get_remote_version(post_id)
|
||||
if not remote_version:
|
||||
return True # 远程不存在,需要更新
|
||||
return local_version != remote_version
|
||||
|
||||
# ========== 插件发布 ==========
|
||||
|
||||
def publish_plugin_from_file(
|
||||
self, file_path: str, force: bool = False, auto_create: bool = True
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
从文件发布插件(支持首次创建和更新)
|
||||
|
||||
Args:
|
||||
file_path: 插件文件路径
|
||||
force: 是否强制更新(忽略版本检查)
|
||||
auto_create: 如果没有 openwebui_id,是否自动创建新帖子
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
metadata = self._parse_frontmatter(content)
|
||||
if not metadata:
|
||||
return False, "No frontmatter found"
|
||||
|
||||
title = metadata.get("title")
|
||||
if not title:
|
||||
return False, "No title in frontmatter"
|
||||
|
||||
post_id = metadata.get("openwebui_id") or metadata.get("post_id")
|
||||
local_version = metadata.get("version")
|
||||
|
||||
# 查找 README
|
||||
readme_content = self._find_readme(file_path)
|
||||
|
||||
# 查找并上传图片
|
||||
media_urls = None
|
||||
image_path = self._find_image(file_path)
|
||||
if image_path:
|
||||
print(f" Found image: {os.path.basename(image_path)}")
|
||||
image_url = self.upload_image(image_path)
|
||||
if image_url:
|
||||
print(f" Uploaded image: {image_url}")
|
||||
media_urls = [image_url]
|
||||
|
||||
# 如果没有 post_id,尝试创建新帖子
|
||||
if not post_id:
|
||||
if not auto_create:
|
||||
return False, "No openwebui_id found and auto_create is disabled"
|
||||
|
||||
print(f" Creating new post for: {title}")
|
||||
new_post_id = self.create_plugin(
|
||||
title=title,
|
||||
source_code=content,
|
||||
readme_content=readme_content or metadata.get("description", ""),
|
||||
metadata=metadata,
|
||||
media_urls=media_urls,
|
||||
)
|
||||
|
||||
if new_post_id:
|
||||
# 将新 ID 写回本地文件
|
||||
self._inject_id_to_file(file_path, new_post_id)
|
||||
return True, f"Created new post (ID: {new_post_id})"
|
||||
return False, "Failed to create new post"
|
||||
|
||||
# 获取远程帖子信息(只需获取一次)
|
||||
remote_post = None
|
||||
if post_id:
|
||||
remote_post = self.get_post(post_id)
|
||||
|
||||
# 版本检查(仅对更新有效)
|
||||
if not force and local_version and remote_post:
|
||||
remote_version = (
|
||||
remote_post.get("data", {})
|
||||
.get("function", {})
|
||||
.get("meta", {})
|
||||
.get("manifest", {})
|
||||
.get("version")
|
||||
)
|
||||
|
||||
version_changed = local_version != remote_version
|
||||
|
||||
# 检查 README 是否变化
|
||||
readme_changed = False
|
||||
remote_content = remote_post.get("content", "")
|
||||
local_content = readme_content or metadata.get("description", "")
|
||||
|
||||
# 简单的内容比较 (去除首尾空白)
|
||||
if (local_content or "").strip() != (remote_content or "").strip():
|
||||
readme_changed = True
|
||||
|
||||
if not version_changed and not readme_changed:
|
||||
return (
|
||||
True,
|
||||
f"Skipped: version {local_version} matches remote and no README changes",
|
||||
)
|
||||
|
||||
if readme_changed and not version_changed:
|
||||
print(
|
||||
f" ℹ️ Version match ({local_version}) but README changed. Updating..."
|
||||
)
|
||||
|
||||
# 更新
|
||||
success = self.update_plugin(
|
||||
post_id=post_id,
|
||||
source_code=content,
|
||||
readme_content=readme_content or metadata.get("description", ""),
|
||||
metadata=metadata,
|
||||
media_urls=media_urls,
|
||||
)
|
||||
|
||||
if success:
|
||||
if local_version:
|
||||
return True, f"Updated to version {local_version}"
|
||||
return True, "Updated plugin"
|
||||
return False, "Update failed"
|
||||
|
||||
def _parse_frontmatter(self, content: str) -> Dict[str, str]:
|
||||
"""解析插件文件的 frontmatter"""
|
||||
match = re.search(r'^\s*"""\n(.*?)\n"""', content, re.DOTALL)
|
||||
if not match:
|
||||
match = re.search(r'"""\n(.*?)\n"""', content, re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
|
||||
frontmatter = match.group(1)
|
||||
meta = {}
|
||||
for line in frontmatter.split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta
|
||||
|
||||
def _find_readme(self, plugin_file_path: str) -> Optional[str]:
|
||||
"""查找插件对应的 README 文件"""
|
||||
plugin_dir = os.path.dirname(plugin_file_path)
|
||||
base_name = os.path.basename(plugin_file_path).lower()
|
||||
|
||||
# 确定优先顺序
|
||||
if base_name.endswith("_cn.py"):
|
||||
readme_files = ["README_CN.md", "README.md"]
|
||||
else:
|
||||
readme_files = ["README.md", "README_CN.md"]
|
||||
|
||||
for readme_name in readme_files:
|
||||
readme_path = os.path.join(plugin_dir, readme_name)
|
||||
if os.path.exists(readme_path):
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
return None
|
||||
|
||||
def _find_image(self, plugin_file_path: str) -> Optional[str]:
|
||||
"""
|
||||
查找插件对应的图片文件
|
||||
图片名称需要和插件文件名一致(不含扩展名)
|
||||
|
||||
例如:
|
||||
export_to_word.py -> export_to_word.png / export_to_word.jpg
|
||||
"""
|
||||
plugin_dir = os.path.dirname(plugin_file_path)
|
||||
plugin_name = os.path.splitext(os.path.basename(plugin_file_path))[0]
|
||||
|
||||
# 支持的图片格式
|
||||
image_extensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"]
|
||||
|
||||
for ext in image_extensions:
|
||||
image_path = os.path.join(plugin_dir, plugin_name + ext)
|
||||
if os.path.exists(image_path):
|
||||
return image_path
|
||||
return None
|
||||
|
||||
def _inject_id_to_file(self, file_path: str, post_id: str) -> bool:
|
||||
"""
|
||||
将新创建的帖子 ID 写回本地插件文件的 frontmatter
|
||||
|
||||
Args:
|
||||
file_path: 插件文件路径
|
||||
post_id: 新创建的帖子 ID
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = []
|
||||
inserted = False
|
||||
in_frontmatter = False
|
||||
|
||||
for line in lines:
|
||||
# Check for start/end of frontmatter
|
||||
if line.strip() == '"""':
|
||||
if not in_frontmatter:
|
||||
in_frontmatter = True
|
||||
else:
|
||||
in_frontmatter = False
|
||||
|
||||
new_lines.append(line)
|
||||
|
||||
# Insert after version line
|
||||
if (
|
||||
in_frontmatter
|
||||
and not inserted
|
||||
and line.strip().startswith("version:")
|
||||
):
|
||||
new_lines.append(f"openwebui_id: {post_id}\n")
|
||||
inserted = True
|
||||
print(f" Injected openwebui_id: {post_id}")
|
||||
|
||||
if inserted:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
return True
|
||||
|
||||
print(f" Warning: Could not inject ID (no version line found)")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error injecting ID to file: {e}")
|
||||
return False
|
||||
|
||||
# ========== 统计功能 ==========
|
||||
|
||||
def generate_stats(self, posts: List[Dict]) -> Dict:
|
||||
"""
|
||||
生成统计数据
|
||||
|
||||
Args:
|
||||
posts: 帖子列表
|
||||
|
||||
Returns:
|
||||
统计数据字典
|
||||
"""
|
||||
stats = {
|
||||
"total_posts": len(posts),
|
||||
"total_downloads": 0,
|
||||
"total_likes": 0,
|
||||
"posts_by_type": {},
|
||||
"posts_detail": [],
|
||||
"generated_at": datetime.now(BEIJING_TZ).isoformat(),
|
||||
}
|
||||
|
||||
for post in posts:
|
||||
downloads = post.get("downloadCount", 0)
|
||||
likes = post.get("likeCount", 0)
|
||||
post_type = post.get("type", "unknown")
|
||||
|
||||
stats["total_downloads"] += downloads
|
||||
stats["total_likes"] += likes
|
||||
stats["posts_by_type"][post_type] = (
|
||||
stats["posts_by_type"].get(post_type, 0) + 1
|
||||
)
|
||||
|
||||
stats["posts_detail"].append(
|
||||
{
|
||||
"id": post.get("id"),
|
||||
"title": post.get("title"),
|
||||
"type": post_type,
|
||||
"downloads": downloads,
|
||||
"likes": likes,
|
||||
"created_at": post.get("createdAt"),
|
||||
"updated_at": post.get("updatedAt"),
|
||||
}
|
||||
)
|
||||
|
||||
# 按下载量排序
|
||||
stats["posts_detail"].sort(key=lambda x: x["downloads"], reverse=True)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def get_client(api_key: Optional[str] = None) -> OpenWebUICommunityClient:
|
||||
"""
|
||||
获取客户端实例
|
||||
|
||||
Args:
|
||||
api_key: API Key,如果为 None 则从环境变量获取
|
||||
|
||||
Returns:
|
||||
OpenWebUICommunityClient 实例
|
||||
"""
|
||||
key = api_key or os.environ.get("OPENWEBUI_API_KEY")
|
||||
if not key:
|
||||
raise ValueError("OPENWEBUI_API_KEY not set")
|
||||
return OpenWebUICommunityClient(key)
|
||||
@@ -157,7 +157,10 @@ class OpenWebUIStats:
|
||||
stats["total_comments"] += post.get("commentCount", 0)
|
||||
|
||||
# 解析 data 字段 - 正确路径: data.function.meta
|
||||
function_data = post.get("data", {}).get("function", {})
|
||||
function_data = post.get("data", {})
|
||||
if function_data is None:
|
||||
function_data = {}
|
||||
function_data = function_data.get("function", {})
|
||||
meta = function_data.get("meta", {})
|
||||
manifest = meta.get("manifest", {})
|
||||
post_type = meta.get("type", function_data.get("type", "unknown"))
|
||||
@@ -331,6 +334,67 @@ class OpenWebUIStats:
|
||||
json.dump(stats, f, ensure_ascii=False, indent=2)
|
||||
print(f"✅ JSON 数据已保存到: {filepath}")
|
||||
|
||||
def generate_shields_endpoints(self, stats: dict, output_dir: str = "docs/badges"):
|
||||
"""
|
||||
生成 Shields.io endpoint JSON 文件
|
||||
|
||||
Args:
|
||||
stats: 统计数据
|
||||
output_dir: 输出目录
|
||||
"""
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def format_number(n: int) -> str:
|
||||
"""格式化数字为易读格式"""
|
||||
if n >= 1000000:
|
||||
return f"{n/1000000:.1f}M"
|
||||
elif n >= 1000:
|
||||
return f"{n/1000:.1f}k"
|
||||
return str(n)
|
||||
|
||||
# 各种徽章数据
|
||||
badges = {
|
||||
"downloads": {
|
||||
"schemaVersion": 1,
|
||||
"label": "downloads",
|
||||
"message": format_number(stats["total_downloads"]),
|
||||
"color": "blue",
|
||||
"namedLogo": "openwebui",
|
||||
},
|
||||
"plugins": {
|
||||
"schemaVersion": 1,
|
||||
"label": "plugins",
|
||||
"message": str(stats["total_posts"]),
|
||||
"color": "green",
|
||||
},
|
||||
"followers": {
|
||||
"schemaVersion": 1,
|
||||
"label": "followers",
|
||||
"message": format_number(stats.get("user", {}).get("followers", 0)),
|
||||
"color": "blue",
|
||||
},
|
||||
"points": {
|
||||
"schemaVersion": 1,
|
||||
"label": "points",
|
||||
"message": format_number(stats.get("user", {}).get("total_points", 0)),
|
||||
"color": "orange",
|
||||
},
|
||||
"upvotes": {
|
||||
"schemaVersion": 1,
|
||||
"label": "upvotes",
|
||||
"message": format_number(stats["total_upvotes"]),
|
||||
"color": "brightgreen",
|
||||
},
|
||||
}
|
||||
|
||||
for name, data in badges.items():
|
||||
filepath = Path(output_dir) / f"{name}.json"
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
print(f" 📊 Generated badge: {name}.json")
|
||||
|
||||
print(f"✅ Shields.io endpoints saved to: {output_dir}/")
|
||||
|
||||
def generate_readme_stats(self, stats: dict, lang: str = "zh") -> str:
|
||||
"""
|
||||
生成 README 统计徽章区域
|
||||
@@ -350,7 +414,8 @@ class OpenWebUIStats:
|
||||
"author_header": "| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |",
|
||||
"header": "| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |",
|
||||
"top6_title": "### 🔥 热门插件 Top 6",
|
||||
"top6_header": "| 排名 | 插件 | 下载 | 浏览 |",
|
||||
"top6_updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
|
||||
"top6_header": "| 排名 | 插件 | 版本 | 下载 | 浏览 | 更新日期 |",
|
||||
"full_stats": "*完整统计请查看 [社区统计报告](./docs/community-stats.zh.md)*",
|
||||
},
|
||||
"en": {
|
||||
@@ -359,7 +424,8 @@ class OpenWebUIStats:
|
||||
"author_header": "| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |",
|
||||
"header": "| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |",
|
||||
"top6_title": "### 🔥 Top 6 Popular Plugins",
|
||||
"top6_header": "| Rank | Plugin | Downloads | Views |",
|
||||
"top6_updated": f"> 🕐 Auto-updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
|
||||
"top6_header": "| Rank | Plugin | Version | Downloads | Views | Updated |",
|
||||
"full_stats": "*See full stats in [Community Stats Report](./docs/community-stats.md)*",
|
||||
},
|
||||
}
|
||||
@@ -398,14 +464,16 @@ class OpenWebUIStats:
|
||||
# Top 6 热门插件
|
||||
lines.append(t["top6_title"])
|
||||
lines.append("")
|
||||
lines.append(t["top6_updated"])
|
||||
lines.append("")
|
||||
lines.append(t["top6_header"])
|
||||
lines.append("|:---:|------|:---:|:---:|")
|
||||
lines.append("|:---:|------|:---:|:---:|:---:|:---:|")
|
||||
|
||||
medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣", "6️⃣"]
|
||||
for i, post in enumerate(top_plugins):
|
||||
medal = medals[i] if i < len(medals) else str(i + 1)
|
||||
lines.append(
|
||||
f"| {medal} | [{post['title']}]({post['url']}) | {post['downloads']} | {post['views']} |"
|
||||
f"| {medal} | [{post['title']}]({post['url']}) | {post['version']} | {post['downloads']} | {post['views']} | {post['updated_at']} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
@@ -537,6 +605,10 @@ def main():
|
||||
json_path = script_dir / "docs" / "community-stats.json"
|
||||
stats_client.save_json(stats, str(json_path))
|
||||
|
||||
# 生成 Shields.io endpoint JSON (用于动态徽章)
|
||||
badges_dir = script_dir / "docs" / "badges"
|
||||
stats_client.generate_shields_endpoints(stats, str(badges_dir))
|
||||
|
||||
# 更新 README 文件
|
||||
readme_path = script_dir / "README.md"
|
||||
readme_cn_path = script_dir / "README_CN.md"
|
||||
|
||||
@@ -1,261 +1,231 @@
|
||||
"""
|
||||
Publish plugins to OpenWebUI Community
|
||||
使用 OpenWebUICommunityClient 发布插件到官方社区
|
||||
|
||||
用法:
|
||||
python scripts/publish_plugin.py # 更新已发布的插件(版本变化时)
|
||||
python scripts/publish_plugin.py --force # 强制更新所有已发布的插件
|
||||
python scripts/publish_plugin.py --new plugins/actions/xxx # 首次发布指定目录的新插件
|
||||
python scripts/publish_plugin.py --new plugins/actions/xxx --force # 强制发布新插件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import re
|
||||
import argparse
|
||||
|
||||
# Add current directory to path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from openwebui_community_client import get_client
|
||||
|
||||
|
||||
def parse_frontmatter(content):
|
||||
"""Extracts metadata from the python file docstring."""
|
||||
# Allow leading whitespace and handle potential shebangs
|
||||
match = re.search(r'^\s*"""\n(.*?)\n"""', content, re.DOTALL)
|
||||
if not match:
|
||||
# Fallback for files starting with comments or shebangs
|
||||
match = re.search(r'"""\n(.*?)\n"""', content, re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
def find_existing_plugins(plugins_dir: str) -> list:
|
||||
"""查找所有已发布的插件文件(有 openwebui_id 的)"""
|
||||
plugins = []
|
||||
for root, _, files in os.walk(plugins_dir):
|
||||
for file in files:
|
||||
if file.endswith(".py") and not file.startswith("__"):
|
||||
file_path = os.path.join(root, file)
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read(2000)
|
||||
|
||||
frontmatter = match.group(1)
|
||||
meta = {}
|
||||
for line in frontmatter.split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta
|
||||
id_match = re.search(
|
||||
r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content
|
||||
)
|
||||
if id_match:
|
||||
plugins.append(
|
||||
{
|
||||
"file_path": file_path,
|
||||
"post_id": id_match.group(1).strip(),
|
||||
}
|
||||
)
|
||||
return plugins
|
||||
|
||||
|
||||
def sync_frontmatter(file_path, content, meta, post_data):
|
||||
"""Syncs remote metadata back to local file frontmatter."""
|
||||
changed = False
|
||||
new_meta = meta.copy()
|
||||
def find_new_plugins_in_dir(target_dir: str) -> list:
|
||||
"""查找指定目录中没有 openwebui_id 的新插件"""
|
||||
plugins = []
|
||||
|
||||
# 1. Sync ID
|
||||
if "openwebui_id" not in new_meta and "post_id" not in new_meta:
|
||||
new_meta["openwebui_id"] = post_data.get("id")
|
||||
changed = True
|
||||
if not os.path.isdir(target_dir):
|
||||
print(f"Error: {target_dir} is not a directory")
|
||||
return plugins
|
||||
|
||||
# 2. Sync Icon URL (often set in UI)
|
||||
manifest = (
|
||||
post_data.get("data", {})
|
||||
.get("function", {})
|
||||
.get("meta", {})
|
||||
.get("manifest", {})
|
||||
)
|
||||
if "icon_url" not in new_meta and manifest.get("icon_url"):
|
||||
new_meta["icon_url"] = manifest.get("icon_url")
|
||||
changed = True
|
||||
for file in os.listdir(target_dir):
|
||||
if file.endswith(".py") and not file.startswith("__"):
|
||||
file_path = os.path.join(target_dir, file)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
# 3. Sync other fields if missing locally
|
||||
for field in ["author", "author_url", "funding_url"]:
|
||||
if field not in new_meta and manifest.get(field):
|
||||
new_meta[field] = manifest.get(field)
|
||||
changed = True
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read(2000)
|
||||
|
||||
if changed:
|
||||
print(f" Syncing metadata back to {os.path.basename(file_path)}...")
|
||||
# Reconstruct frontmatter
|
||||
# We need to replace the content inside the first """ ... """
|
||||
# This is a bit fragile with regex but sufficient for standard files
|
||||
# 检查是否有 frontmatter (title)
|
||||
title_match = re.search(r"title:\s*(.+)", content)
|
||||
if not title_match:
|
||||
continue
|
||||
|
||||
def replacement(match):
|
||||
lines = []
|
||||
# Keep existing description or comments if we can't parse them easily?
|
||||
# Actually, let's just reconstruct the key-values we know
|
||||
# and try to preserve the description if it was at the end
|
||||
# 检查是否已有 ID
|
||||
id_match = re.search(r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content)
|
||||
if id_match:
|
||||
print(f" ⚠️ {file} already has ID, will update instead")
|
||||
plugins.append(
|
||||
{
|
||||
"file_path": file_path,
|
||||
"title": title_match.group(1).strip(),
|
||||
"post_id": id_match.group(1).strip(),
|
||||
"is_new": False,
|
||||
}
|
||||
)
|
||||
else:
|
||||
plugins.append(
|
||||
{
|
||||
"file_path": file_path,
|
||||
"title": title_match.group(1).strip(),
|
||||
"post_id": None,
|
||||
"is_new": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Simple approach: Rebuild the whole block based on new_meta
|
||||
# This might lose comments inside the frontmatter, but standard format is simple keys
|
||||
|
||||
# Try to preserve order: title, author, ..., version, ..., description
|
||||
ordered_keys = [
|
||||
"title",
|
||||
"author",
|
||||
"author_url",
|
||||
"funding_url",
|
||||
"version",
|
||||
"openwebui_id",
|
||||
"icon_url",
|
||||
"requirements",
|
||||
"description",
|
||||
]
|
||||
|
||||
block = ['"""']
|
||||
|
||||
# Add known keys in order
|
||||
for k in ordered_keys:
|
||||
if k in new_meta:
|
||||
block.append(f"{k}: {new_meta[k]}")
|
||||
|
||||
# Add any other custom keys
|
||||
for k, v in new_meta.items():
|
||||
if k not in ordered_keys:
|
||||
block.append(f"{k}: {v}")
|
||||
|
||||
block.append('"""')
|
||||
return "\n".join(block)
|
||||
|
||||
new_content = re.sub(
|
||||
r'^"""\n(.*?)\n"""', replacement, content, count=1, flags=re.DOTALL
|
||||
)
|
||||
|
||||
# If regex didn't match (e.g. leading whitespace), try with whitespace
|
||||
if new_content == content:
|
||||
new_content = re.sub(
|
||||
r'^\s*"""\n(.*?)\n"""', replacement, content, count=1, flags=re.DOTALL
|
||||
)
|
||||
|
||||
if new_content != content:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
return new_content # Return updated content
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def update_plugin(file_path, post_id, token):
|
||||
print(f"Processing {os.path.basename(file_path)} (ID: {post_id})...")
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
meta = parse_frontmatter(content)
|
||||
if not meta:
|
||||
print(f" Skipping: No frontmatter found.")
|
||||
return False
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# 1. Fetch existing post
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://api.openwebui.com/api/v1/posts/{post_id}", headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
post_data = response.json()
|
||||
except Exception as e:
|
||||
print(f" Error fetching post: {e}")
|
||||
return False
|
||||
|
||||
# 1.5 Sync Metadata back to local file
|
||||
try:
|
||||
content = sync_frontmatter(file_path, content, meta, post_data)
|
||||
# Re-parse meta in case it changed
|
||||
meta = parse_frontmatter(content)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to sync local metadata: {e}")
|
||||
|
||||
# 2. Update ONLY Content and Manifest
|
||||
try:
|
||||
# Ensure structure exists before populating nested fields
|
||||
if "data" not in post_data:
|
||||
post_data["data"] = {}
|
||||
if "function" not in post_data["data"]:
|
||||
post_data["data"]["function"] = {}
|
||||
if "meta" not in post_data["data"]["function"]:
|
||||
post_data["data"]["function"]["meta"] = {}
|
||||
if "manifest" not in post_data["data"]["function"]["meta"]:
|
||||
post_data["data"]["function"]["meta"]["manifest"] = {}
|
||||
|
||||
# Update 1: The Source Code (Inner Content)
|
||||
post_data["data"]["function"]["content"] = content
|
||||
|
||||
# Update 2: The Post Body/README (Outer Content)
|
||||
# Try to find a matching README file
|
||||
plugin_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path).lower()
|
||||
readme_content = None
|
||||
|
||||
# Determine preferred README filename
|
||||
readme_files = []
|
||||
if base_name.endswith("_cn.py"):
|
||||
readme_files = ["README_CN.md", "README.md"]
|
||||
else:
|
||||
readme_files = ["README.md", "README_CN.md"]
|
||||
|
||||
for readme_name in readme_files:
|
||||
readme_path = os.path.join(plugin_dir, readme_name)
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
print(f" Using README: {readme_name}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" Error reading {readme_name}: {e}")
|
||||
|
||||
if readme_content:
|
||||
post_data["content"] = readme_content
|
||||
elif "description" in meta:
|
||||
post_data["content"] = meta["description"]
|
||||
else:
|
||||
post_data["content"] = ""
|
||||
|
||||
# Update Manifest (Metadata)
|
||||
post_data["data"]["function"]["meta"]["manifest"].update(meta)
|
||||
|
||||
# Sync top-level fields for consistency
|
||||
if "title" in meta:
|
||||
post_data["title"] = meta["title"]
|
||||
post_data["data"]["function"]["name"] = meta["title"]
|
||||
if "description" in meta:
|
||||
post_data["data"]["function"]["meta"]["description"] = meta["description"]
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error preparing update: {e}")
|
||||
return False
|
||||
|
||||
# 3. Submit Update
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://api.openwebui.com/api/v1/posts/{post_id}/update",
|
||||
headers=headers,
|
||||
json=post_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(f" ✅ Success!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ❌ Failed: {e}")
|
||||
return False
|
||||
return plugins
|
||||
|
||||
|
||||
def main():
|
||||
token = os.environ.get("OPENWEBUI_API_KEY")
|
||||
if not token:
|
||||
print("Error: OPENWEBUI_API_KEY not set.")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Publish plugins to OpenWebUI Market",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Update existing plugins (with version check)
|
||||
python scripts/publish_plugin.py
|
||||
|
||||
# Force update all existing plugins
|
||||
python scripts/publish_plugin.py --force
|
||||
|
||||
# Publish new plugins from a specific directory
|
||||
python scripts/publish_plugin.py --new plugins/actions/summary
|
||||
|
||||
# Preview what would be done
|
||||
python scripts/publish_plugin.py --new plugins/actions/summary --dry-run
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Force update even if version matches"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--new",
|
||||
metavar="DIR",
|
||||
help="Publish new plugins from the specified directory (required for first-time publishing)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without actually publishing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
plugins_dir = os.path.join(base_dir, "plugins")
|
||||
|
||||
count = 0
|
||||
# Walk through plugins directory
|
||||
for root, _, files in os.walk(plugins_dir):
|
||||
for file in files:
|
||||
if file.endswith(".py"):
|
||||
file_path = os.path.join(root, file)
|
||||
updated = 0
|
||||
created = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
# Check for ID in file content without full parse first
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read(
|
||||
2000
|
||||
) # Read first 2000 chars is enough for frontmatter
|
||||
# 处理新插件发布
|
||||
if args.new:
|
||||
target_dir = args.new
|
||||
if not os.path.isabs(target_dir):
|
||||
target_dir = os.path.join(base_dir, target_dir)
|
||||
|
||||
# Simple regex to find ID
|
||||
id_match = re.search(
|
||||
r"(?:openwebui_id|post_id):\s*([a-z0-9-]+)", content
|
||||
)
|
||||
print(f"🆕 Publishing new plugins from: {target_dir}\n")
|
||||
new_plugins = find_new_plugins_in_dir(target_dir)
|
||||
|
||||
if id_match:
|
||||
post_id = id_match.group(1).strip()
|
||||
update_plugin(file_path, post_id, token)
|
||||
count += 1
|
||||
if not new_plugins:
|
||||
print("No plugins found in the specified directory.")
|
||||
return
|
||||
|
||||
print(f"\nFinished. Updated {count} plugins.")
|
||||
for plugin in new_plugins:
|
||||
file_path = plugin["file_path"]
|
||||
file_name = os.path.basename(file_path)
|
||||
title = plugin["title"]
|
||||
is_new = plugin.get("is_new", True)
|
||||
|
||||
if is_new:
|
||||
print(f"🆕 Creating: {file_name} ({title})")
|
||||
else:
|
||||
print(f"📦 Updating: {file_name} (ID: {plugin['post_id'][:8]}...)")
|
||||
|
||||
if args.dry_run:
|
||||
print(f" [DRY-RUN] Would {'create' if is_new else 'update'}")
|
||||
continue
|
||||
|
||||
success, message = client.publish_plugin_from_file(
|
||||
file_path, force=args.force, auto_create=True
|
||||
)
|
||||
|
||||
if success:
|
||||
if "Created" in message:
|
||||
print(f" 🎉 {message}")
|
||||
created += 1
|
||||
elif "Skipped" in message:
|
||||
print(f" ⏭️ {message}")
|
||||
skipped += 1
|
||||
else:
|
||||
print(f" ✅ {message}")
|
||||
updated += 1
|
||||
else:
|
||||
print(f" ❌ {message}")
|
||||
failed += 1
|
||||
|
||||
# 处理已有插件更新
|
||||
else:
|
||||
existing_plugins = find_existing_plugins(plugins_dir)
|
||||
print(f"Found {len(existing_plugins)} existing plugins with OpenWebUI ID.\n")
|
||||
|
||||
if not existing_plugins:
|
||||
print("No existing plugins to update.")
|
||||
print(
|
||||
"\n💡 Tip: Use --new <dir> to publish new plugins from a specific directory"
|
||||
)
|
||||
return
|
||||
|
||||
for plugin in existing_plugins:
|
||||
file_path = plugin["file_path"]
|
||||
file_name = os.path.basename(file_path)
|
||||
post_id = plugin["post_id"]
|
||||
|
||||
print(f"📦 {file_name} (ID: {post_id[:8]}...)")
|
||||
|
||||
if args.dry_run:
|
||||
print(f" [DRY-RUN] Would update")
|
||||
continue
|
||||
|
||||
success, message = client.publish_plugin_from_file(
|
||||
file_path, force=args.force, auto_create=False # 不自动创建,只更新
|
||||
)
|
||||
|
||||
if success:
|
||||
if "Skipped" in message:
|
||||
print(f" ⏭️ {message}")
|
||||
skipped += 1
|
||||
else:
|
||||
print(f" ✅ {message}")
|
||||
updated += 1
|
||||
else:
|
||||
print(f" ❌ {message}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(
|
||||
f"Finished: {created} created, {updated} updated, {skipped} skipped, {failed} failed"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""
|
||||
Sync OpenWebUI Post IDs to local plugin files
|
||||
同步远程插件 ID 到本地文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
@@ -6,11 +11,12 @@ import difflib
|
||||
# Add current directory to path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from openwebui_community_client import get_client
|
||||
|
||||
try:
|
||||
from openwebui_stats import OpenWebUIStats
|
||||
from extract_plugin_versions import scan_plugins_directory
|
||||
except ImportError:
|
||||
print("Error: Helper scripts not found.")
|
||||
print("Error: extract_plugin_versions.py not found.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -60,13 +66,13 @@ def insert_id_into_file(file_path, post_id):
|
||||
|
||||
|
||||
def main():
|
||||
token = os.environ.get("OPENWEBUI_API_KEY")
|
||||
if not token:
|
||||
print("Error: OPENWEBUI_API_KEY environment variable not set.")
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Fetching remote posts...")
|
||||
client = OpenWebUIStats(token)
|
||||
print("Fetching remote posts from OpenWebUI Community...")
|
||||
remote_posts = client.get_all_posts()
|
||||
print(f"Fetched {len(remote_posts)} remote posts.")
|
||||
|
||||
|
||||