Compare commits
44 Commits
v2026.01.0
...
v2026.01.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
856f76cd27 | ||
|
|
28bb9000d8 | ||
|
|
d0b9e46b74 | ||
|
|
a0a4d31715 | ||
|
|
d5f394f5f1 | ||
|
|
a477d2baad | ||
|
|
8471680efe | ||
|
|
4d44b72dab | ||
|
|
88e14d251a | ||
|
|
e446b6474d | ||
|
|
a2eda6e5af | ||
|
|
fe80c8bee3 | ||
|
|
133315d0c6 | ||
|
|
3907644282 | ||
|
|
d8cde2115f | ||
|
|
0ce63b548f | ||
|
|
06e81c0194 | ||
|
|
3763e6501d | ||
|
|
5911f75641 | ||
|
|
f936181a37 | ||
|
|
a7651f33a4 | ||
|
|
45ddf5092b | ||
|
|
61294e90e4 | ||
|
|
8619405802 | ||
|
|
f0017ffacd | ||
|
|
65fe16e185 | ||
|
|
136e7e9021 | ||
|
|
c1a660a2a1 | ||
|
|
53f04debaf | ||
|
|
4b9790df00 | ||
|
|
58452a8441 | ||
|
|
e104161007 | ||
|
|
6de0d6fbe4 | ||
|
|
28d55c1469 | ||
|
|
59933e9361 | ||
|
|
7cbd0e2920 | ||
|
|
88038b35cc | ||
|
|
1fd7d90284 | ||
|
|
aee9c93bfb | ||
|
|
3951f7f91d | ||
|
|
3680fcf39f | ||
|
|
593a9ce22b | ||
|
|
fe497cccb7 | ||
|
|
88aa7e156a |
@@ -25,6 +25,10 @@ Every plugin **MUST** have bilingual versions for both code and documentation:
|
||||
- **Valves**: Use `pydantic` for configuration.
|
||||
- **Database**: Re-use `open_webui.internal.db` shared connection.
|
||||
- **User Context**: Use `_get_user_context` helper method.
|
||||
- **Chat API**: For message updates, follow the "OpenWebUI Chat API 更新规范" in `.github/copilot-instructions.md`.
|
||||
- Use Event API for immediate UI updates
|
||||
- Use Chat Persistence API for database storage
|
||||
- Always update both `messages[]` and `history.messages`
|
||||
|
||||
### Commit Messages
|
||||
- **Language**: **English ONLY**. Do not use Chinese in commit messages.
|
||||
@@ -55,9 +59,13 @@ When adding or updating a plugin, you **MUST** update the following documentatio
|
||||
Reference: `.github/workflows/release.yml`
|
||||
|
||||
### Version Bumping
|
||||
- **Rule**: Any change to plugin logic **MUST** be accompanied by a version bump in the docstring.
|
||||
- **Rule**: Version bump is required **ONLY when the user explicitly requests a release**. Regular code changes do NOT require version bumps.
|
||||
- **Format**: Semantic Versioning (e.g., `1.0.0` -> `1.0.1`).
|
||||
- **Consistency**: Update version in **ALL** locations:
|
||||
- **When to Bump**: Only update the version when:
|
||||
- User says "发布" / "release" / "bump version"
|
||||
- User explicitly asks to prepare for release
|
||||
- **Agent Initiative**: After completing significant changes (new features, bug fixes, or multiple code modifications), the agent **SHOULD proactively ask** the user if they want to release a new version. If confirmed, update all version-related files.
|
||||
- **Consistency**: When bumping, update version in **ALL** locations:
|
||||
1. English Code (`.py`)
|
||||
2. Chinese Code (`.py`)
|
||||
3. English README (`README.md`)
|
||||
@@ -94,9 +102,6 @@ Before committing:
|
||||
|
||||
## 5. Git Operations (Agent Rules)
|
||||
|
||||
**CRITICAL RULE FOR AGENTS**:
|
||||
Strictly follow the rules defined in `.github/copilot-instructions.md` → **Git Operations (Agent Rules)** section.
|
||||
|
||||
- **No Auto-Push**: Agents **MUST NOT** automatically push changes to the remote `main` branch.
|
||||
- **Local Commit Only**: All changes must be committed locally.
|
||||
- **User Approval**: Pushing to remote requires explicit user action or approval.
|
||||
|
||||
|
||||
477
.github/copilot-instructions.md
vendored
477
.github/copilot-instructions.md
vendored
@@ -13,13 +13,13 @@ This document defines the standard conventions and best practices for OpenWebUI
|
||||
每个插件必须提供两个版本:
|
||||
|
||||
1. **英文版本**: `plugin_name.py` - 英文界面、提示词和注释
|
||||
2. **中文版本**: `plugin_name_cn.py` 或 `插件中文名.py` - 中文界面、提示词和注释
|
||||
2. **中文版本**: `plugin_name_cn.py` - 中文界面、提示词和注释
|
||||
|
||||
示例:
|
||||
```
|
||||
plugins/actions/export_to_docx/
|
||||
├── export_to_word.py # English version
|
||||
├── 导出为Word.py # Chinese version
|
||||
├── export_to_word_cn.py # Chinese version
|
||||
├── README.md # English documentation
|
||||
└── README_CN.md # Chinese documentation
|
||||
```
|
||||
@@ -798,10 +798,371 @@ For iframe plugins to access parent document theme information, users need to co
|
||||
- [ ] 使用 logging 而非 print
|
||||
- [ ] 测试双语界面
|
||||
- [ ] **一致性检查 (Consistency Check)**:
|
||||
- [ ] 更新 `README.md` 插件列表
|
||||
- [ ] 更新 `README_CN.md` 插件列表
|
||||
- [ ] 更新/创建 `docs/` 下的对应文档
|
||||
- [ ] 确保文档版本号与代码一致
|
||||
|
||||
---
|
||||
|
||||
## 🚀 高级开发模式 (Advanced Development Patterns)
|
||||
|
||||
### 混合服务端-客户端生成 (Hybrid Server-Client Generation)
|
||||
|
||||
对于需要复杂前端渲染(如 Mermaid 图表、ECharts)但最终生成文件(如 DOCX、PDF)的场景,建议采用混合模式:
|
||||
|
||||
1. **服务端 (Python)**:
|
||||
* 处理文本解析、Markdown 转换、文档结构构建。
|
||||
* 为复杂组件生成**占位符**(如带有特定 ID 或元数据的图片/文本块)。
|
||||
* 将半成品文件(如 Base64 编码的 ZIP/DOCX)发送给前端。
|
||||
|
||||
2. **客户端 (JavaScript)**:
|
||||
* 在浏览器中加载半成品文件(使用 JSZip 等库)。
|
||||
* 利用浏览器能力渲染复杂组件(如 `mermaid.render`)。
|
||||
* 将渲染结果(SVG/PNG)回填到占位符位置。
|
||||
* 触发最终文件的下载。
|
||||
|
||||
**优势**:
|
||||
* 无需在服务端安装 Headless Browser(如 Puppeteer),降低部署复杂度。
|
||||
* 利用用户浏览器的计算能力。
|
||||
* 支持动态、交互式内容的静态化导出。
|
||||
|
||||
### 原生 Word 公式支持 (Native Word Math Support)
|
||||
|
||||
对于需要生成高质量数学公式的 Word 文档,推荐使用 `latex2mathml` + `mathml2omml` 组合:
|
||||
|
||||
1. **LaTeX -> MathML**: 使用 `latex2mathml` 将 LaTeX 字符串转换为标准 MathML。
|
||||
2. **MathML -> OMML**: 使用 `mathml2omml` 将 MathML 转换为 Office Math Markup Language (OMML)。
|
||||
3. **插入 Word**: 将 OMML XML 插入到 `python-docx` 的段落中。
|
||||
|
||||
```python
|
||||
# 示例代码
|
||||
from latex2mathml.converter import convert as latex2mathml
|
||||
from mathml2omml import convert as mathml2omml
|
||||
|
||||
def add_math(paragraph, latex_str):
|
||||
mathml = latex2mathml(latex_str)
|
||||
omml = mathml2omml(mathml)
|
||||
# ... 插入 OMML 到 paragraph._element ...
|
||||
```
|
||||
|
||||
### JS 渲染并嵌入 Markdown (JS Render to Markdown)
|
||||
|
||||
对于需要复杂前端渲染(如 AntV 图表、Mermaid 图表、ECharts)但希望结果**持久化为纯 Markdown 格式**的场景,推荐使用 Data URL 嵌入模式:
|
||||
|
||||
#### 工作流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Plugin Workflow │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. Python Action │
|
||||
│ ├── 分析消息内容 │
|
||||
│ ├── 调用 LLM 生成结构化数据(可选) │
|
||||
│ └── 通过 __event_call__ 发送 JS 代码到前端 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2. Browser JS (via __event_call__) │
|
||||
│ ├── 动态加载可视化库(如 AntV、Mermaid) │
|
||||
│ ├── 离屏渲染 SVG/Canvas │
|
||||
│ ├── 使用 toDataURL() 导出 Base64 Data URL │
|
||||
│ └── 通过 REST API 更新消息内容 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 3. Markdown 渲染 │
|
||||
│ └── 显示  │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 核心实现代码
|
||||
|
||||
**Python 端(发送 JS 执行):**
|
||||
|
||||
```python
|
||||
async def action(self, body, __event_call__, __metadata__, ...):
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
|
||||
# 生成 JS 代码
|
||||
js_code = self._generate_js_code(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
data=processed_data, # 可视化所需数据
|
||||
)
|
||||
|
||||
# 执行 JS
|
||||
if __event_call__:
|
||||
await __event_call__({
|
||||
"type": "execute",
|
||||
"data": {"code": js_code}
|
||||
})
|
||||
```
|
||||
|
||||
**JavaScript 端(渲染并回写):**
|
||||
|
||||
```javascript
|
||||
(async function() {
|
||||
// 1. 动态加载可视化库
|
||||
if (typeof VisualizationLib === 'undefined') {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.example.com/lib.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 创建离屏容器
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:absolute;left:-9999px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 3. 渲染可视化
|
||||
const instance = new VisualizationLib({ container, ... });
|
||||
instance.render(data);
|
||||
|
||||
// 4. 导出为 Data URL
|
||||
const dataUrl = await instance.toDataURL({ type: 'svg', embedResources: true });
|
||||
// 或手动转换 SVG:
|
||||
// const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
// const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
// const dataUrl = "data:image/svg+xml;base64," + base64;
|
||||
|
||||
// 5. 清理
|
||||
instance.destroy();
|
||||
document.body.removeChild(container);
|
||||
|
||||
// 6. 生成 Markdown 图片
|
||||
const markdownImage = ``;
|
||||
|
||||
// 7. 通过 API 更新消息
|
||||
const token = localStorage.getItem("token");
|
||||
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: originalContent + "\n\n" + markdownImage }
|
||||
})
|
||||
});
|
||||
})();
|
||||
```
|
||||
|
||||
#### 优势
|
||||
|
||||
- **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块
|
||||
- **高效存储**:图片上传至 `/api/v1/files`,避免 Base64 字符串膨胀聊天记录
|
||||
- **持久化**:通过 API 回写,消息重新加载后图片仍然存在
|
||||
- **跨平台**:任何支持 Markdown 图片的客户端都能显示
|
||||
- **无服务端渲染依赖**:利用用户浏览器的渲染能力
|
||||
|
||||
#### 与 HTML 注入模式对比
|
||||
|
||||
| 特性 | HTML 注入 (`\`\`\`html`) | JS 渲染 + Markdown 图片 |
|
||||
|------|-------------------------|------------------------|
|
||||
| 输出格式 | HTML 代码块 | Markdown 图片 |
|
||||
| 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 |
|
||||
| 外部依赖 | 需要加载 JS 库 | 依赖 `/api/v1/files` 存储 |
|
||||
| 持久化 | 依赖浏览器渲染 | ✅ 永久可见 |
|
||||
| 文件导出 | 需特殊处理 | ✅ 直接导出 |
|
||||
| 适用场景 | 交互式内容 | 信息图、图表快照 |
|
||||
|
||||
#### 参考实现
|
||||
|
||||
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV Infographic 生成并嵌入
|
||||
- `plugins/actions/js-render-poc/js_render_poc.py` - 基础概念验证
|
||||
|
||||
### OpenWebUI Chat API 更新规范 (Chat API Update Specification)
|
||||
|
||||
当插件需要修改消息内容并持久化到数据库时,必须遵循 OpenWebUI 的 Backend-Controlled API 流程。
|
||||
|
||||
When a plugin needs to modify message content and persist it to the database, follow OpenWebUI's Backend-Controlled API flow.
|
||||
|
||||
#### 核心概念 (Core Concepts)
|
||||
|
||||
1. **Event API** (`/api/v1/chats/{chatId}/messages/{messageId}/event`)
|
||||
- 用于**即时更新前端显示**,用户无需刷新页面
|
||||
- 是可选的,部分版本可能不支持
|
||||
- 仅影响当前会话的 UI,不持久化
|
||||
|
||||
2. **Chat Persistence API** (`/api/v1/chats/{chatId}`)
|
||||
- 用于**持久化到数据库**,确保刷新页面后数据仍存在
|
||||
- 必须同时更新 `messages[]` 数组和 `history.messages` 对象
|
||||
- 是消息持久化的唯一可靠方式
|
||||
|
||||
#### 数据结构 (Data Structure)
|
||||
|
||||
OpenWebUI 的 Chat 对象包含两个关键位置存储消息内容:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"chat": {
|
||||
"id": "chat-uuid",
|
||||
"title": "Chat Title",
|
||||
"messages": [ // 1️⃣ 消息数组
|
||||
{ "id": "msg-1", "role": "user", "content": "..." },
|
||||
{ "id": "msg-2", "role": "assistant", "content": "..." }
|
||||
],
|
||||
"history": {
|
||||
"current_id": "msg-2",
|
||||
"messages": { // 2️⃣ 消息索引对象
|
||||
"msg-1": { "id": "msg-1", "role": "user", "content": "..." },
|
||||
"msg-2": { "id": "msg-2", "role": "assistant", "content": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **重要**:修改消息时,**必须同时更新两个位置**,否则可能导致数据不一致。
|
||||
|
||||
#### 标准实现流程 (Standard Implementation)
|
||||
|
||||
```javascript
|
||||
(async function() {
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// 1️⃣ 获取当前 Chat 数据
|
||||
const getResponse = await fetch(`/api/v1/chats/${chatId}`, {
|
||||
method: "GET",
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
const chatData = await getResponse.json();
|
||||
|
||||
// 2️⃣ 使用 map 遍历 messages,只修改目标消息
|
||||
let newContent = "";
|
||||
const updatedMessages = chatData.chat.messages.map(m => {
|
||||
if (m.id === messageId) {
|
||||
const originalContent = m.content || "";
|
||||
newContent = originalContent + "\n\n" + newMarkdown;
|
||||
|
||||
// 3️⃣ 同时更新 history.messages 中对应的消息
|
||||
if (chatData.chat.history && chatData.chat.history.messages) {
|
||||
if (chatData.chat.history.messages[messageId]) {
|
||||
chatData.chat.history.messages[messageId].content = newContent;
|
||||
}
|
||||
}
|
||||
|
||||
// 4️⃣ 保留消息的其他属性,只修改 content
|
||||
return { ...m, content: newContent };
|
||||
}
|
||||
return m; // 其他消息原样返回
|
||||
});
|
||||
|
||||
// 5️⃣ 通过 Event API 即时更新前端(可选)
|
||||
try {
|
||||
await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "chat:message",
|
||||
data: { content: newContent }
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
// Event API 是可选的,继续执行持久化
|
||||
console.log("Event API not available, continuing...");
|
||||
}
|
||||
|
||||
// 6️⃣ 持久化到数据库(必须)
|
||||
const updatePayload = {
|
||||
chat: {
|
||||
...chatData.chat, // 保留所有原有属性
|
||||
messages: updatedMessages
|
||||
// history 已在上面原地修改
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(`/api/v1/chats/${chatId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(updatePayload)
|
||||
});
|
||||
})();
|
||||
```
|
||||
|
||||
#### 最佳实践 (Best Practices)
|
||||
|
||||
1. **保留原有结构**:使用展开运算符 `...chatData.chat` 和 `...m` 确保不丢失任何原有属性
|
||||
2. **双位置更新**:必须同时更新 `messages[]` 和 `history.messages[id]`
|
||||
3. **错误处理**:Event API 调用应包裹在 try-catch 中,失败时继续持久化
|
||||
4. **重试机制**:对持久化 API 实现重试逻辑,提高可靠性
|
||||
|
||||
```javascript
|
||||
// 带重试的请求函数
|
||||
const fetchWithRetry = async (url, options, retries = 3) => {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (response.ok) return response;
|
||||
if (i < retries - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1))); // 指数退避
|
||||
}
|
||||
} catch (e) {
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
5. **禁止使用的 API**:不要使用 `/api/v1/chats/{chatId}/share` 作为持久化备用方案,该 API 用于分享功能,不是更新功能
|
||||
|
||||
#### 提取 Chat ID 和 Message ID (Extracting IDs)
|
||||
|
||||
```python
|
||||
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""从 body 或 metadata 中提取 chat_id"""
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
chat_id = body_metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""从 body 或 metadata 中提取 message_id"""
|
||||
if isinstance(body, dict):
|
||||
message_id = body.get("id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
message_id = body_metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
return ""
|
||||
```
|
||||
|
||||
#### 参考实现
|
||||
|
||||
- `plugins/actions/smart-mind-map/smart_mind_map.py` - 思维导图图片模式实现
|
||||
- 官方文档: [Backend-Controlled, UI-Compatible API Flow](https://docs.openwebui.com/tutorials/tips/backend-controlled-ui-compatible-api-flow)
|
||||
|
||||
---
|
||||
|
||||
@@ -833,13 +1194,35 @@ For iframe plugins to access parent document theme information, users need to co
|
||||
|
||||
### 发布前必须完成 (Pre-release Requirements)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 版本号**仅在用户明确要求发布时**才需要更新。日常代码更改**无需**更新版本号。
|
||||
|
||||
**触发版本更新的关键词**:
|
||||
- 用户说 "发布"、"release"、"bump version"
|
||||
- 用户明确要求准备发布
|
||||
|
||||
**Agent 主动询问发布 (Agent-Initiated Release Prompt)**:
|
||||
|
||||
当 Agent 完成以下类型的更改后,**应主动询问**用户是否需要发布新版本:
|
||||
|
||||
| 更改类型 | 示例 | 是否询问发布 |
|
||||
|---------|------|-------------|
|
||||
| 新功能 | 新增导出格式、新的配置选项 | ✅ 询问 |
|
||||
| 重要 Bug 修复 | 修复导致崩溃或数据丢失的问题 | ✅ 询问 |
|
||||
| 累积多次更改 | 同一插件在会话中被修改 >= 3 次 | ✅ 询问 |
|
||||
| 小优化 | 代码清理、格式符号处理 | ❌ 不询问 |
|
||||
| 文档更新 | 只改 README、注释 | ❌ 不询问 |
|
||||
|
||||
如果用户确认发布,Agent 需要更新所有版本相关的文件(代码、README、docs 等)。
|
||||
|
||||
**发布时需要完成**:
|
||||
1. ✅ **更新版本号** - 修改插件文档字符串中的 `version` 字段
|
||||
2. ✅ **中英文版本同步** - 确保两个版本的版本号一致
|
||||
|
||||
```python
|
||||
"""
|
||||
title: My Plugin
|
||||
version: 0.2.0 # <- 必须更新这里!
|
||||
version: 0.2.0 # <- 发布时更新这里!
|
||||
...
|
||||
"""
|
||||
```
|
||||
@@ -984,3 +1367,83 @@ Follow the [Conventional Commits](https://www.conventionalcommits.org/) specific
|
||||
❌ **Bad:**
|
||||
- `新增导出PDF插件` (Chinese is not allowed)
|
||||
- `update code` (Too vague)
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Git Operations (Agent Rules)
|
||||
|
||||
**重要规则 (CRITICAL RULES FOR AI AGENTS)**:
|
||||
|
||||
AI Agent(如 Copilot、Gemini、Claude 等)在执行 Git 操作时必须遵守以下规则:
|
||||
|
||||
| 操作 (Operation) | 允许 (Allowed) | 说明 (Description) |
|
||||
|-----------------|---------------|---------------------|
|
||||
| 创建功能分支 | ✅ 允许 | `git checkout -b feature/xxx` |
|
||||
| 推送到功能分支 | ✅ 允许 | `git push origin feature/xxx` |
|
||||
| 直接推送到 main | ❌ 禁止 | `git push origin main` 需要用户手动执行 |
|
||||
| 合并到 main | ❌ 禁止 | 任何合并操作需要用户明确批准 |
|
||||
| Rebase 到 main | ❌ 禁止 | 任何 rebase 操作需要用户明确批准 |
|
||||
|
||||
**规则详解 (Rule Details)**:
|
||||
|
||||
1. **Feature Branches Allowed**: Agent **可以**创建新的功能分支并推送到远程仓库
|
||||
2. **No Direct Push to Main**: Agent **禁止**直接推送任何更改到 `main` 分支
|
||||
3. **No Auto-Merge**: Agent **禁止**在未经用户明确批准的情况下合并任何分支到 `main`
|
||||
4. **User Approval Required**: 任何影响 `main` 分支的操作(push、merge、rebase)都需要用户明确批准
|
||||
|
||||
> [!CAUTION]
|
||||
> 违反上述规则可能导致代码库不稳定或触发意外的 CI/CD 流程。Agent 应始终在功能分支上工作,并让用户决定何时合并到主分支。
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 长时间运行任务通知 (Long-running Task Notifications)
|
||||
|
||||
如果一个前台任务(Foreground Task)的运行时间预计超过 **3秒**,必须实现用户通知机制,以避免用户感到困惑。
|
||||
|
||||
**要求 (Requirements):**
|
||||
|
||||
1. **初始通知 (Initial Notification)**: 任务开始时**立即**发送第一条通知,告知用户正在处理中(例如:“正在使用 AI 生成中...”)。
|
||||
2. **周期性通知 (Periodic Notification)**: 之后每隔 **5秒** 发送一次通知,告知用户任务仍在运行中。
|
||||
3. **完成清理 (Cleanup)**: 任务完成后,应自动取消通知任务。
|
||||
|
||||
**代码示例 (Code Example):**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def long_running_task_with_notification(self, event_emitter, ...):
|
||||
# 定义实际任务
|
||||
async def actual_task():
|
||||
# ... 执行耗时操作 ...
|
||||
return result
|
||||
|
||||
# 定义通知任务
|
||||
async def notification_task():
|
||||
# 立即发送首次通知
|
||||
if event_emitter:
|
||||
await self._send_notification(event_emitter, "info", "正在使用 AI 生成中...")
|
||||
|
||||
# 之后每5秒通知一次
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
if event_emitter:
|
||||
await self._send_notification(event_emitter, "info", "仍在处理中,请耐心等待...")
|
||||
|
||||
# 并发运行任务
|
||||
task_future = asyncio.ensure_future(actual_task())
|
||||
notify_future = asyncio.ensure_future(notification_task())
|
||||
|
||||
# 等待任务完成
|
||||
done, pending = await asyncio.wait(
|
||||
[task_future, notify_future],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
# 取消通知任务
|
||||
if not notify_future.done():
|
||||
notify_future.cancel()
|
||||
|
||||
# 获取结果
|
||||
if task_future in done:
|
||||
return task_future.result()
|
||||
```
|
||||
|
||||
54
.github/workflows/community-stats.yml
vendored
Normal file
54
.github/workflows/community-stats.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# OpenWebUI 社区统计报告自动生成
|
||||
# 每小时自动获取并更新社区统计数据
|
||||
|
||||
name: Community Stats
|
||||
|
||||
on:
|
||||
# 每小时整点运行
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-stats:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install requests python-dotenv
|
||||
|
||||
- name: Generate stats report
|
||||
env:
|
||||
OPENWEBUI_API_KEY: ${{ secrets.OPENWEBUI_API_KEY }}
|
||||
OPENWEBUI_USER_ID: ${{ secrets.OPENWEBUI_USER_ID }}
|
||||
run: |
|
||||
python scripts/openwebui_stats.py
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
git diff --quiet docs/community-stats.md docs/community-stats.en.md README.md README_CN.md || echo "changed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add docs/community-stats.md docs/community-stats.en.md docs/community-stats.json README.md README_CN.md
|
||||
git commit -m "📊 更新社区统计数据 $(date +'%Y-%m-%d')"
|
||||
git push
|
||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -180,14 +180,23 @@ jobs:
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
# Auto-generate version based on date and run number
|
||||
VERSION="v$(date +'%Y.%m.%d')-${{ github.run_number }}"
|
||||
# Auto-generate version based on date and daily release count
|
||||
TODAY=$(date +'%Y.%m.%d')
|
||||
TODAY_PREFIX="v${TODAY}-"
|
||||
|
||||
# Count existing releases with today's date prefix
|
||||
EXISTING_COUNT=$(gh release list --limit 100 | grep -c "^${TODAY_PREFIX}" || echo "0")
|
||||
NEXT_NUM=$((EXISTING_COUNT + 1))
|
||||
|
||||
VERSION="${TODAY_PREFIX}${NEXT_NUM}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Release version: $VERSION"
|
||||
@@ -341,7 +350,13 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Check if there are any .py files to upload
|
||||
if [ -d release_plugins ] && [ -n "$(find release_plugins -type f -name '*.py' 2>/dev/null)" ]; then
|
||||
echo "Uploading plugin files..."
|
||||
find release_plugins -type f -name "*.py" -print0 | xargs -0 gh release upload ${{ steps.version.outputs.version }} --clobber
|
||||
else
|
||||
echo "No plugin files to upload. Skipping asset upload."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
|
||||
42
README.md
42
README.md
@@ -4,7 +4,31 @@ English | [中文](./README_CN.md)
|
||||
|
||||
A collection of enhancements, plugins, and prompts for [OpenWebUI](https://github.com/open-webui/open-webui), developed and curated for personal use to extend functionality and improve experience.
|
||||
|
||||
[Contributing](./CONTRIBUTING.md)
|
||||
<!-- STATS_START -->
|
||||
## 📊 Community Stats
|
||||
|
||||
> 🕐 Auto-updated: 2026-01-06 21:19
|
||||
|
||||
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
|
||||
|:---:|:---:|:---:|:---:|
|
||||
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **43** | **62** | **17** |
|
||||
|
||||
| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |
|
||||
|:---:|:---:|:---:|:---:|:---:|
|
||||
| **11** | **794** | **8481** | **54** | **48** |
|
||||
|
||||
### 🔥 Top 5 Popular Plugins
|
||||
|
||||
| Rank | Plugin | Downloads | Views |
|
||||
|:---:|------|:---:|:---:|
|
||||
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 240 | 2133 |
|
||||
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 171 | 459 |
|
||||
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1236 |
|
||||
| 4️⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 76 | 1421 |
|
||||
| 5️⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 909 |
|
||||
|
||||
*See full stats in [Community Stats Report](./docs/community-stats.md)*
|
||||
<!-- STATS_END -->
|
||||
|
||||
## 📦 Project Contents
|
||||
|
||||
@@ -60,10 +84,16 @@ This project is a collection of resources and does not require a Python environm
|
||||
|
||||
### Using Plugins
|
||||
|
||||
1. Browse the `/plugins` directory and download the plugin file (`.py`) you need.
|
||||
2. Go to OpenWebUI **Admin Panel** -> **Settings** -> **Plugins**.
|
||||
3. Click the upload button and select the `.py` file you just downloaded.
|
||||
4. Once uploaded, refresh the page to enable the plugin in your chat settings or toolbar.
|
||||
1. **Install from OpenWebUI Community (Recommended)**:
|
||||
- Visit my profile: [Fu-Jie's Profile](https://openwebui.com/u/Fu-Jie)
|
||||
- Browse the plugins and select the one you like.
|
||||
- Click "Get" to import it directly into your OpenWebUI instance.
|
||||
|
||||
2. **Manual Installation**:
|
||||
- Browse the `/plugins` directory and download the plugin file (`.py`) you need.
|
||||
- Go to OpenWebUI **Admin Panel** -> **Settings** -> **Plugins**.
|
||||
- Click the upload button and select the `.py` file you just downloaded.
|
||||
- Once uploaded, refresh the page to enable the plugin in your chat settings or toolbar.
|
||||
|
||||
### Contributing
|
||||
|
||||
@@ -71,3 +101,5 @@ If you have great prompts or plugins to share:
|
||||
1. Fork this repository.
|
||||
2. Add your files to the appropriate `prompts/` or `plugins/` directory.
|
||||
3. Submit a Pull Request.
|
||||
|
||||
[Contributing](./CONTRIBUTING.md)
|
||||
|
||||
81
README_CN.md
81
README_CN.md
@@ -2,7 +2,37 @@
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Plugins)
|
||||
OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词等资源。
|
||||
|
||||
<!-- STATS_START -->
|
||||
## 📊 社区统计
|
||||
|
||||
> 🕐 自动更新于 2026-01-06 21:19
|
||||
|
||||
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
|
||||
|:---:|:---:|:---:|:---:|
|
||||
| [Fu-Jie](https://openwebui.com/u/Fu-Jie) | **43** | **62** | **17** |
|
||||
|
||||
| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |
|
||||
|:---:|:---:|:---:|:---:|:---:|
|
||||
| **11** | **794** | **8481** | **54** | **48** |
|
||||
|
||||
### 🔥 热门插件 Top 5
|
||||
|
||||
| 排名 | 插件 | 下载 | 浏览 |
|
||||
|:---:|------|:---:|:---:|
|
||||
| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 240 | 2133 |
|
||||
| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 171 | 459 |
|
||||
| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1236 |
|
||||
| 4️⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 76 | 1421 |
|
||||
| 5️⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 909 |
|
||||
|
||||
*完整统计请查看 [社区统计报告](./docs/community-stats.md)*
|
||||
<!-- STATS_END -->
|
||||
|
||||
## 📦 项目内容
|
||||
|
||||
### 🧩 插件 (Plugins)
|
||||
|
||||
位于 `plugins/` 目录,包含各类 Python 编写的功能增强插件:
|
||||
|
||||
@@ -19,7 +49,6 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
|
||||
- **Context Enhancement** (`context_enhancement_filter`): 上下文增强过滤器。
|
||||
- **Gemini Manifold Companion** (`gemini_manifold_companion`): Gemini Manifold 配套增强。
|
||||
|
||||
|
||||
#### Pipes (模型管道)
|
||||
- **Gemini Manifold** (`gemini_mainfold`): 集成 Gemini 模型的管道。
|
||||
|
||||
@@ -31,40 +60,10 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
|
||||
位于 `prompts/` 目录,包含精心调优的 System Prompts:
|
||||
|
||||
- **Coding**: 编程辅助类提示词。
|
||||
- **Marketing**: 营销文案类提示词。(`/prompts/marketing`): 内容创作、品牌策划、市场分析相关的提示词
|
||||
- **Marketing**: 营销文案类提示词。
|
||||
|
||||
每个提示词都独立保存为 Markdown 文件,可直接在 OpenWebUI 中使用。
|
||||
|
||||
### 🔧 插件 (Plugins)
|
||||
|
||||
{{ ... }}
|
||||
|
||||
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)
|
||||
|
||||
## 📦 项目内容
|
||||
|
||||
### 🎯 提示词 (Prompts)
|
||||
|
||||
位于 `/prompts` 目录,包含针对不同领域的优质提示词模板:
|
||||
|
||||
- **编程类** (`/prompts/coding`): 代码生成、调试、优化相关的提示词
|
||||
- **营销类** (`/prompts/marketing`): 内容创作、品牌策划、市场分析相关的提示词
|
||||
|
||||
每个提示词都独立保存为 Markdown 文件,可直接在 OpenWebUI 中使用。
|
||||
|
||||
### 🔧 插件 (Plugins)
|
||||
|
||||
位于 `/plugins` 目录,提供三种类型的插件扩展:
|
||||
|
||||
- **过滤器 (Filters)** - 在用户输入发送给 LLM 前进行处理和优化
|
||||
- 异步上下文压缩:智能压缩长上下文,优化 token 使用效率
|
||||
|
||||
- **动作 (Actions)** - 自定义功能,从聊天中触发
|
||||
- 思维导图生成:快速生成和导出思维导图
|
||||
|
||||
- **管道 (Pipes)** - 对 LLM 响应进行处理和增强
|
||||
- 各类响应处理和格式化插件
|
||||
|
||||
## 📖 开发文档
|
||||
|
||||
位于 `docs/zh/` 目录:
|
||||
@@ -87,10 +86,16 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
|
||||
|
||||
### 使用插件 (Plugins)
|
||||
|
||||
1. 在 `/plugins` 目录中浏览并下载你需要的插件文件 (`.py`)。
|
||||
2. 打开 OpenWebUI 的 **管理员面板 (Admin Panel)** -> **设置 (Settings)** -> **插件 (Plugins)**。
|
||||
3. 点击上传按钮,选择刚才下载的 `.py` 文件。
|
||||
4. 上传成功后,刷新页面,你就可以在聊天设置或工具栏中启用该插件了。
|
||||
1. **从 OpenWebUI 社区安装 (推荐)**:
|
||||
- 访问我的主页: [Fu-Jie's Profile](https://openwebui.com/u/Fu-Jie)
|
||||
- 浏览插件列表,选择你喜欢的插件。
|
||||
- 点击 "Get" 按钮,将其直接导入到你的 OpenWebUI 实例中。
|
||||
|
||||
2. **手动安装**:
|
||||
- 在 `/plugins` 目录中浏览并下载你需要的插件文件 (`.py`)。
|
||||
- 打开 OpenWebUI 的 **管理员面板 (Admin Panel)** -> **设置 (Settings)** -> **插件 (Plugins)**。
|
||||
- 点击上传按钮,选择刚才下载的 `.py` 文件。
|
||||
- 上传成功后,刷新页面,你就可以在聊天设置或工具栏中启用该插件了。
|
||||
|
||||
### 贡献代码
|
||||
|
||||
@@ -98,3 +103,5 @@ OpenWebUI 增强功能集合。包含个人开发与收集的### 🧩 插件 (Pl
|
||||
1. Fork 本仓库。
|
||||
2. 将你的文件添加到对应的 `prompts/` 或 `plugins/` 目录。
|
||||
3. 提交 Pull Request。
|
||||
|
||||
[贡献指南](./CONTRIBUTING.md) | [更新日志](./CHANGELOG.md)
|
||||
|
||||
35
docs/community-stats.en.md
Normal file
35
docs/community-stats.en.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 📊 OpenWebUI Community Stats Report
|
||||
|
||||
> 📅 Updated: 2026-01-06 21:19
|
||||
|
||||
## 📈 Overview
|
||||
|
||||
| Metric | Value |
|
||||
|------|------|
|
||||
| 📝 Total Posts | 11 |
|
||||
| ⬇️ Total Downloads | 794 |
|
||||
| 👁️ Total Views | 8481 |
|
||||
| 👍 Total Upvotes | 54 |
|
||||
| 💾 Total Saves | 48 |
|
||||
| 💬 Total Comments | 13 |
|
||||
|
||||
## 📂 By Type
|
||||
|
||||
- **action**: 9
|
||||
- **filter**: 2
|
||||
|
||||
## 📋 Posts List
|
||||
|
||||
| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |
|
||||
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.8.2 | 240 | 2133 | 10 | 15 | 2026-01-03 |
|
||||
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 171 | 459 | 3 | 3 | 2026-01-03 |
|
||||
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1236 | 5 | 9 | 2025-12-31 |
|
||||
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 76 | 1421 | 8 | 5 | 2026-01-03 |
|
||||
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 909 | 6 | 8 | 2026-01-03 |
|
||||
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 504 | 5 | 4 | 2026-01-05 |
|
||||
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 398 | 3 | 0 | 2025-12-29 |
|
||||
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 15 | 752 | 7 | 1 | 2026-01-05 |
|
||||
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 249 | 2 | 1 | 2025-12-31 |
|
||||
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 309 | 3 | 1 | 2025-12-31 |
|
||||
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 111 | 2 | 1 | 2025-12-31 |
|
||||
203
docs/community-stats.json
Normal file
203
docs/community-stats.json
Normal file
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"total_posts": 11,
|
||||
"total_downloads": 794,
|
||||
"total_views": 8481,
|
||||
"total_upvotes": 54,
|
||||
"total_downvotes": 1,
|
||||
"total_saves": 48,
|
||||
"total_comments": 13,
|
||||
"by_type": {
|
||||
"action": 9,
|
||||
"filter": 2
|
||||
},
|
||||
"posts": [
|
||||
{
|
||||
"title": "Turn Any Text into Beautiful Mind Maps",
|
||||
"slug": "turn_any_text_into_beautiful_mind_maps_3094c59a",
|
||||
"type": "action",
|
||||
"version": "0.8.2",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.",
|
||||
"downloads": 240,
|
||||
"views": 2133,
|
||||
"upvotes": 10,
|
||||
"saves": 15,
|
||||
"comments": 8,
|
||||
"created_at": "2025-12-30",
|
||||
"updated_at": "2026-01-03",
|
||||
"url": "https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a"
|
||||
},
|
||||
{
|
||||
"title": "Export to Excel",
|
||||
"slug": "export_mulit_table_to_excel_244b8f9d",
|
||||
"type": "action",
|
||||
"version": "0.3.6",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
|
||||
"downloads": 171,
|
||||
"views": 459,
|
||||
"upvotes": 3,
|
||||
"saves": 3,
|
||||
"comments": 0,
|
||||
"created_at": "2025-05-30",
|
||||
"updated_at": "2026-01-03",
|
||||
"url": "https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d"
|
||||
},
|
||||
{
|
||||
"title": "Async Context Compression",
|
||||
"slug": "async_context_compression_b1655bc8",
|
||||
"type": "filter",
|
||||
"version": "1.1.0",
|
||||
"author": "Fu-Jie",
|
||||
"description": "This filter automatically compresses long conversation contexts by intelligently summarizing and removing intermediate messages while preserving critical information, thereby significantly reducing token consumption.",
|
||||
"downloads": 112,
|
||||
"views": 1236,
|
||||
"upvotes": 5,
|
||||
"saves": 9,
|
||||
"comments": 0,
|
||||
"created_at": "2025-11-08",
|
||||
"updated_at": "2025-12-31",
|
||||
"url": "https://openwebui.com/posts/async_context_compression_b1655bc8"
|
||||
},
|
||||
{
|
||||
"title": "Flash Card ",
|
||||
"slug": "flash_card_65a2ea8f",
|
||||
"type": "action",
|
||||
"version": "0.2.4",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
|
||||
"downloads": 76,
|
||||
"views": 1421,
|
||||
"upvotes": 8,
|
||||
"saves": 5,
|
||||
"comments": 2,
|
||||
"created_at": "2025-12-30",
|
||||
"updated_at": "2026-01-03",
|
||||
"url": "https://openwebui.com/posts/flash_card_65a2ea8f"
|
||||
},
|
||||
{
|
||||
"title": "Smart Infographic",
|
||||
"slug": "smart_infographic_ad6f0c7f",
|
||||
"type": "action",
|
||||
"version": "1.3.2",
|
||||
"author": "jeff",
|
||||
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
|
||||
"downloads": 65,
|
||||
"views": 909,
|
||||
"upvotes": 6,
|
||||
"saves": 8,
|
||||
"comments": 2,
|
||||
"created_at": "2025-12-28",
|
||||
"updated_at": "2026-01-03",
|
||||
"url": "https://openwebui.com/posts/smart_infographic_ad6f0c7f"
|
||||
},
|
||||
{
|
||||
"title": "Export to Word (Enhanced Formatting)",
|
||||
"slug": "export_to_word_enhanced_formatting_fca6a315",
|
||||
"type": "action",
|
||||
"version": "0.4.0",
|
||||
"author": "Fu-Jie",
|
||||
"description": "Export the current conversation to a formatted Word doc with syntax highlighting, AI-generated titles, and perfect Markdown rendering (tables, quotes, lists).",
|
||||
"downloads": 51,
|
||||
"views": 504,
|
||||
"upvotes": 5,
|
||||
"saves": 4,
|
||||
"comments": 0,
|
||||
"created_at": "2026-01-03",
|
||||
"updated_at": "2026-01-05",
|
||||
"url": "https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315"
|
||||
},
|
||||
{
|
||||
"title": "智能信息图",
|
||||
"slug": "智能信息图_e04a48ff",
|
||||
"type": "action",
|
||||
"version": "1.3.1",
|
||||
"author": "jeff",
|
||||
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
|
||||
"downloads": 33,
|
||||
"views": 398,
|
||||
"upvotes": 3,
|
||||
"saves": 0,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-28",
|
||||
"updated_at": "2025-12-29",
|
||||
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
|
||||
},
|
||||
{
|
||||
"title": "导出为 Word-支持公式、流程图、表格和代码块",
|
||||
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
|
||||
"type": "action",
|
||||
"version": "0.4.1",
|
||||
"author": "Fu-Jie",
|
||||
"description": "将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持中英文无乱码。",
|
||||
"downloads": 15,
|
||||
"views": 752,
|
||||
"upvotes": 7,
|
||||
"saves": 1,
|
||||
"comments": 1,
|
||||
"created_at": "2026-01-04",
|
||||
"updated_at": "2026-01-05",
|
||||
"url": "https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0"
|
||||
},
|
||||
{
|
||||
"title": "智能生成交互式思维导图,帮助用户可视化知识",
|
||||
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
|
||||
"type": "action",
|
||||
"version": "0.8.0",
|
||||
"author": "",
|
||||
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
|
||||
"downloads": 14,
|
||||
"views": 249,
|
||||
"upvotes": 2,
|
||||
"saves": 1,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-31",
|
||||
"updated_at": "2025-12-31",
|
||||
"url": "https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b"
|
||||
},
|
||||
{
|
||||
"title": "闪记卡生成插件",
|
||||
"slug": "闪记卡生成插件_4a31eac3",
|
||||
"type": "action",
|
||||
"version": "0.2.2",
|
||||
"author": "Fu-Jie",
|
||||
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
|
||||
"downloads": 12,
|
||||
"views": 309,
|
||||
"upvotes": 3,
|
||||
"saves": 1,
|
||||
"comments": 0,
|
||||
"created_at": "2025-12-30",
|
||||
"updated_at": "2025-12-31",
|
||||
"url": "https://openwebui.com/posts/闪记卡生成插件_4a31eac3"
|
||||
},
|
||||
{
|
||||
"title": "异步上下文压缩",
|
||||
"slug": "异步上下文压缩_5c0617cb",
|
||||
"type": "filter",
|
||||
"version": "1.1.0",
|
||||
"author": "Fu-Jie",
|
||||
"description": "在 LLM 响应完成后进行上下文摘要和压缩",
|
||||
"downloads": 5,
|
||||
"views": 111,
|
||||
"upvotes": 2,
|
||||
"saves": 1,
|
||||
"comments": 0,
|
||||
"created_at": "2025-11-08",
|
||||
"updated_at": "2025-12-31",
|
||||
"url": "https://openwebui.com/posts/异步上下文压缩_5c0617cb"
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"username": "Fu-Jie",
|
||||
"name": "Fu-Jie",
|
||||
"profile_url": "https://openwebui.com/u/Fu-Jie",
|
||||
"profile_image": "https://community.s3.openwebui.com/uploads/users/b15d1348-4347-42b4-b815-e053342d6cb0/profile_d9510745-4bd4-4f8f-a997-4a21847d9300.webp",
|
||||
"followers": 43,
|
||||
"following": 2,
|
||||
"total_points": 62,
|
||||
"post_points": 53,
|
||||
"comment_points": 9,
|
||||
"contributions": 17
|
||||
}
|
||||
}
|
||||
35
docs/community-stats.md
Normal file
35
docs/community-stats.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 📊 OpenWebUI 社区统计报告
|
||||
|
||||
> 📅 更新时间: 2026-01-06 21:19
|
||||
|
||||
## 📈 总览
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 📝 发布数量 | 11 |
|
||||
| ⬇️ 总下载量 | 794 |
|
||||
| 👁️ 总浏览量 | 8481 |
|
||||
| 👍 总点赞数 | 54 |
|
||||
| 💾 总收藏数 | 48 |
|
||||
| 💬 总评论数 | 13 |
|
||||
|
||||
## 📂 按类型分类
|
||||
|
||||
- **action**: 9
|
||||
- **filter**: 2
|
||||
|
||||
## 📋 发布列表
|
||||
|
||||
| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |
|
||||
|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| 1 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | action | 0.8.2 | 240 | 2133 | 10 | 15 | 2026-01-03 |
|
||||
| 2 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | action | 0.3.6 | 171 | 459 | 3 | 3 | 2026-01-03 |
|
||||
| 3 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | filter | 1.1.0 | 112 | 1236 | 5 | 9 | 2025-12-31 |
|
||||
| 4 | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | action | 0.2.4 | 76 | 1421 | 8 | 5 | 2026-01-03 |
|
||||
| 5 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | action | 1.3.2 | 65 | 909 | 6 | 8 | 2026-01-03 |
|
||||
| 6 | [Export to Word (Enhanced Formatting)](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) | action | 0.4.0 | 51 | 504 | 5 | 4 | 2026-01-05 |
|
||||
| 7 | [智能信息图](https://openwebui.com/posts/智能信息图_e04a48ff) | action | 1.3.1 | 33 | 398 | 3 | 0 | 2025-12-29 |
|
||||
| 8 | [导出为 Word-支持公式、流程图、表格和代码块](https://openwebui.com/posts/导出为_word_支持公式流程图表格和代码块_8a6306c0) | action | 0.4.1 | 15 | 752 | 7 | 1 | 2026-01-05 |
|
||||
| 9 | [智能生成交互式思维导图,帮助用户可视化知识](https://openwebui.com/posts/智能生成交互式思维导图帮助用户可视化知识_8d4b097b) | action | 0.8.0 | 14 | 249 | 2 | 1 | 2025-12-31 |
|
||||
| 10 | [闪记卡生成插件](https://openwebui.com/posts/闪记卡生成插件_4a31eac3) | action | 0.2.2 | 12 | 309 | 3 | 1 | 2025-12-31 |
|
||||
| 11 | [异步上下文压缩](https://openwebui.com/posts/异步上下文压缩_5c0617cb) | filter | 1.1.0 | 5 | 111 | 2 | 1 | 2025-12-31 |
|
||||
@@ -235,6 +235,125 @@ llm_response = await generate_chat_completion(
|
||||
)
|
||||
```
|
||||
|
||||
### 4.4 JS Render to Markdown (Data URL Embedding)
|
||||
|
||||
For scenarios requiring complex frontend rendering (e.g., AntV charts, Mermaid diagrams) but wanting **persistent pure Markdown output**, use the Data URL embedding pattern:
|
||||
|
||||
#### Workflow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 1. Python Action │
|
||||
│ ├── Analyze message content │
|
||||
│ ├── Call LLM to generate structured data (optional) │
|
||||
│ └── Send JS code to frontend via __event_call__ │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 2. Browser JS (via __event_call__) │
|
||||
│ ├── Dynamically load visualization library │
|
||||
│ ├── Render SVG/Canvas offscreen │
|
||||
│ ├── Export to Base64 Data URL via toDataURL() │
|
||||
│ └── Update message content via REST API │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 3. Markdown Rendering │
|
||||
│ └── Display  │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Python Side (Send JS for Execution)
|
||||
|
||||
```python
|
||||
async def action(self, body, __event_call__, __metadata__, ...):
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
|
||||
# Generate JS code
|
||||
js_code = self._generate_js_code(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
data=processed_data,
|
||||
)
|
||||
|
||||
# Execute JS
|
||||
if __event_call__:
|
||||
await __event_call__({
|
||||
"type": "execute",
|
||||
"data": {"code": js_code}
|
||||
})
|
||||
```
|
||||
|
||||
#### JavaScript Side (Render and Write-back)
|
||||
|
||||
```javascript
|
||||
(async function() {
|
||||
// 1. Load visualization library
|
||||
if (typeof VisualizationLib === 'undefined') {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.example.com/lib.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create offscreen container
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:absolute;left:-9999px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 3. Render visualization
|
||||
const instance = new VisualizationLib({ container });
|
||||
instance.render(data);
|
||||
|
||||
// 4. Export to Data URL
|
||||
const dataUrl = await instance.toDataURL({ type: 'svg', embedResources: true });
|
||||
|
||||
// 5. Cleanup
|
||||
instance.destroy();
|
||||
document.body.removeChild(container);
|
||||
|
||||
// 6. Generate Markdown image
|
||||
const markdownImage = ``;
|
||||
|
||||
// 7. Update message via API
|
||||
const token = localStorage.getItem("token");
|
||||
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: originalContent + "\n\n" + markdownImage }
|
||||
})
|
||||
});
|
||||
})();
|
||||
```
|
||||
|
||||
#### Benefits
|
||||
|
||||
- **Pure Markdown Output**: Standard Markdown image syntax, no HTML code blocks
|
||||
- **Self-Contained**: Images embedded as Base64 Data URL, no external dependencies
|
||||
- **Persistent**: Via API write-back, images remain after page reload
|
||||
- **Cross-Platform**: Works on any client supporting Markdown images
|
||||
|
||||
#### HTML Injection vs JS Render to Markdown
|
||||
|
||||
| Feature | HTML Injection | JS Render + Markdown |
|
||||
|---------|----------------|----------------------|
|
||||
| Output Format | HTML code block | Markdown image |
|
||||
| Interactivity | ✅ Buttons, animations | ❌ Static image |
|
||||
| External Deps | Requires JS libraries | None (self-contained) |
|
||||
| Persistence | Depends on browser | ✅ Permanent |
|
||||
| File Export | Needs special handling | ✅ Direct export |
|
||||
| Use Case | Interactive content | Infographics, chart snapshots |
|
||||
|
||||
#### Reference Implementations
|
||||
|
||||
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV Infographic + Data URL
|
||||
- `plugins/actions/js-render-poc/js_render_poc.py` - Basic proof of concept
|
||||
|
||||
---
|
||||
|
||||
## 5. Best Practices & Design Principles
|
||||
|
||||
@@ -199,7 +199,124 @@ async def background_job(self, chat_id):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
### 4.3 JS 渲染并嵌入 Markdown (Data URL 嵌入)
|
||||
|
||||
对于需要复杂前端渲染(如 AntV 图表、Mermaid 图表)但希望结果**持久化为纯 Markdown 格式**的场景,推荐使用 Data URL 嵌入模式:
|
||||
|
||||
#### 工作流程
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 1. Python Action │
|
||||
│ ├── 分析消息内容 │
|
||||
│ ├── 调用 LLM 生成结构化数据(可选) │
|
||||
│ └── 通过 __event_call__ 发送 JS 代码到前端 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 2. Browser JS (通过 __event_call__) │
|
||||
│ ├── 动态加载可视化库 │
|
||||
│ ├── 离屏渲染 SVG/Canvas │
|
||||
│ ├── 使用 toDataURL() 导出 Base64 Data URL │
|
||||
│ └── 通过 REST API 更新消息内容 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 3. Markdown 渲染 │
|
||||
│ └── 显示  │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Python 端(发送 JS 执行)
|
||||
|
||||
```python
|
||||
async def action(self, body, __event_call__, __metadata__, ...):
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
|
||||
# 生成 JS 代码
|
||||
js_code = self._generate_js_code(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
data=processed_data,
|
||||
)
|
||||
|
||||
# 执行 JS
|
||||
if __event_call__:
|
||||
await __event_call__({
|
||||
"type": "execute",
|
||||
"data": {"code": js_code}
|
||||
})
|
||||
```
|
||||
|
||||
#### JavaScript 端(渲染并回写)
|
||||
|
||||
```javascript
|
||||
(async function() {
|
||||
// 1. 加载可视化库
|
||||
if (typeof VisualizationLib === 'undefined') {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.example.com/lib.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 创建离屏容器
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:absolute;left:-9999px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 3. 渲染可视化
|
||||
const instance = new VisualizationLib({ container });
|
||||
instance.render(data);
|
||||
|
||||
// 4. 导出为 Data URL
|
||||
const dataUrl = await instance.toDataURL({ type: 'svg', embedResources: true });
|
||||
|
||||
// 5. 清理
|
||||
instance.destroy();
|
||||
document.body.removeChild(container);
|
||||
|
||||
// 6. 生成 Markdown 图片
|
||||
const markdownImage = ``;
|
||||
|
||||
// 7. 通过 API 更新消息
|
||||
const token = localStorage.getItem("token");
|
||||
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: originalContent + "\n\n" + markdownImage }
|
||||
})
|
||||
});
|
||||
})();
|
||||
```
|
||||
|
||||
#### 优势
|
||||
|
||||
- **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块
|
||||
- **自包含**:图片以 Base64 Data URL 嵌入,无外部依赖
|
||||
- **持久化**:通过 API 回写,消息重新加载后图片仍然存在
|
||||
- **跨平台**:任何支持 Markdown 图片的客户端都能显示
|
||||
|
||||
#### HTML 注入 vs JS 渲染嵌入 Markdown
|
||||
|
||||
| 特性 | HTML 注入 | JS 渲染 + Markdown 图片 |
|
||||
|------|----------|------------------------|
|
||||
| 输出格式 | HTML 代码块 | Markdown 图片 |
|
||||
| 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 |
|
||||
| 外部依赖 | 需要加载 JS 库 | 无(图片自包含) |
|
||||
| 持久化 | 依赖浏览器渲染 | ✅ 永久可见 |
|
||||
| 文件导出 | 需特殊处理 | ✅ 直接导出 |
|
||||
| 适用场景 | 交互式内容 | 信息图、图表快照 |
|
||||
|
||||
#### 参考实现
|
||||
|
||||
- `plugins/actions/js-render-poc/infographic_markdown.py` - AntV 信息图 + Data URL
|
||||
- `plugins/actions/js-render-poc/js_render_poc.py` - 基础概念验证
|
||||
|
||||
## 5. 最佳实践与设计原则
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
Open WebUI 通过文件顶部的特定格式注释来识别和展示插件信息。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
**代码示例 (`smart_mind_map_cn.py`):**
|
||||
|
||||
```python
|
||||
"""
|
||||
@@ -45,7 +45,7 @@ description: 智能分析文本内容,生成交互式思维导图,帮助用户
|
||||
|
||||
通过在 `Action` 类内部定义一个 `Valves` Pydantic 模型,可以为插件创建可在 Web UI 中配置的参数。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
**代码示例 (`smart_mind_map_cn.py`):**
|
||||
|
||||
```python
|
||||
class Action:
|
||||
@@ -83,7 +83,7 @@ class Action:
|
||||
|
||||
`action` 方法是插件的执行入口,它是一个异步函数,接收 Open WebUI 传入的上下文信息。
|
||||
|
||||
**代码示例 (`思维导图.py`):**
|
||||
**代码示例 (`smart_mind_map_cn.py`):**
|
||||
|
||||
```python
|
||||
async def action(
|
||||
|
||||
@@ -104,10 +104,16 @@ hide:
|
||||
|
||||
### Using Plugins
|
||||
|
||||
1. Browse the [Plugin Center](plugins/index.md) and download the plugin file (`.py`)
|
||||
2. Open OpenWebUI **Admin Panel** → **Settings** → **Plugins**
|
||||
3. Click the upload button and select the `.py` file
|
||||
4. Refresh the page and enable the plugin in your chat settings
|
||||
1. **Install from OpenWebUI Community (Recommended)**:
|
||||
- Visit my profile: [Fu-Jie's Profile](https://openwebui.com/u/Fu-Jie)
|
||||
- Browse the plugins and select the one you like.
|
||||
- Click "Get" to import it directly into your OpenWebUI instance.
|
||||
|
||||
2. **Manual Installation**:
|
||||
- Browse the [Plugin Center](plugins/index.md) and download the plugin file (`.py`)
|
||||
- Open OpenWebUI **Admin Panel** → **Settings** → **Plugins**
|
||||
- Click the upload button and select the `.py` file
|
||||
- Refresh the page and enable the plugin in your chat settings
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -104,10 +104,16 @@ hide:
|
||||
|
||||
### 使用插件
|
||||
|
||||
1. 浏览[插件中心](plugins/index.md)并下载插件文件(`.py`)
|
||||
2. 打开 OpenWebUI **管理面板** → **设置** → **插件**
|
||||
3. 点击上传按钮并选择 `.py` 文件
|
||||
4. 刷新页面并在聊天设置中启用插件
|
||||
1. **从 OpenWebUI 社区安装 (推荐)**:
|
||||
- 访问我的主页: [Fu-Jie's Profile](https://openwebui.com/u/Fu-Jie)
|
||||
- 浏览插件列表,选择你喜欢的插件。
|
||||
- 点击 "Get" 按钮,将其直接导入到你的 OpenWebUI 实例中。
|
||||
|
||||
2. **手动安装**:
|
||||
- 浏览[插件中心](plugins/index.md)并下载插件文件(`.py`)
|
||||
- 打开 OpenWebUI **管理面板** → **设置** → **插件**
|
||||
- 点击上传按钮并选择 `.py` 文件
|
||||
- 刷新页面并在聊天设置中启用插件
|
||||
|
||||
---
|
||||
|
||||
|
||||
290
docs/js-visualization-guide.md
Normal file
290
docs/js-visualization-guide.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# 使用 JavaScript 生成可视化内容的技术方案
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了在 OpenWebUI Action 插件中使用浏览器端 JavaScript 代码生成可视化内容(如思维导图、信息图等)并将结果保存到消息中的技术方案。
|
||||
|
||||
## 核心架构
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Plugin as Python 插件
|
||||
participant EventCall as __event_call__
|
||||
participant Browser as 浏览器 (JS)
|
||||
participant API as OpenWebUI API
|
||||
participant DB as 数据库
|
||||
|
||||
Plugin->>EventCall: 1. 发送 execute 事件 (含 JS 代码)
|
||||
EventCall->>Browser: 2. 执行 JS 代码
|
||||
Browser->>Browser: 3. 加载可视化库 (D3/Markmap/AntV)
|
||||
Browser->>Browser: 4. 渲染可视化内容
|
||||
Browser->>Browser: 5. 转换为 Base64 Data URI
|
||||
Browser->>API: 6. GET 获取当前消息内容
|
||||
API-->>Browser: 7. 返回消息数据
|
||||
Browser->>API: 8. POST 追加 Markdown 图片到消息
|
||||
API->>DB: 9. 保存更新后的消息
|
||||
```
|
||||
|
||||
## 关键步骤
|
||||
|
||||
### 1. Python 端通过 `__event_call__` 执行 JS
|
||||
|
||||
Python 插件**不直接修改 `body["messages"]`**,而是通过 `__event_call__` 发送 JS 代码让浏览器执行:
|
||||
|
||||
```python
|
||||
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:
|
||||
# 从 body 获取 chat_id 和 message_id
|
||||
chat_id = body.get("chat_id", "")
|
||||
message_id = body.get("id", "") # 注意:body["id"] 是 message_id
|
||||
|
||||
# 通过 __event_call__ 执行 JS 代码
|
||||
if __event_call__:
|
||||
await __event_call__({
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
(async function() {{
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
// ... JS 渲染和 API 更新逻辑 ...
|
||||
}})();
|
||||
"""
|
||||
},
|
||||
})
|
||||
|
||||
# 不修改 body,直接返回
|
||||
return body
|
||||
```
|
||||
|
||||
### 2. JavaScript 加载可视化库
|
||||
|
||||
在浏览器端动态加载所需的 JS 库:
|
||||
|
||||
```javascript
|
||||
// 加载 D3.js
|
||||
if (!window.d3) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// 加载 Markmap (思维导图)
|
||||
if (!window.markmap) {
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/markmap-lib@0.17');
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/markmap-view@0.17');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 渲染并转换为 Data URI
|
||||
|
||||
```javascript
|
||||
// 创建 SVG 元素
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '800');
|
||||
svg.setAttribute('height', '600');
|
||||
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
|
||||
// ... 执行渲染逻辑 (添加图形元素) ...
|
||||
|
||||
// 转换为 Base64 Data URI
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = 'data:image/svg+xml;base64,' + base64;
|
||||
```
|
||||
|
||||
### 4. 获取当前消息内容
|
||||
|
||||
由于 Python 端不传递原始内容,JS 需要通过 API 获取:
|
||||
|
||||
```javascript
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 获取当前聊天数据
|
||||
const getResponse = await fetch(`/api/v1/chats/${chatId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 调用 API 更新消息
|
||||
|
||||
```javascript
|
||||
// 构造新内容:原始内容 + Markdown 图片
|
||||
const markdownImage = ``;
|
||||
const newContent = originalContent + '\n\n' + markdownImage;
|
||||
|
||||
// 调用 API 更新消息
|
||||
const response = 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 (response.ok) {
|
||||
console.log('消息更新成功!');
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
参考 [js_render_poc.py](https://github.com/Fu-Jie/awesome-openwebui/blob/main/plugins/actions/js-render-poc/js_render_poc.py) 获取完整的 PoC 实现。
|
||||
|
||||
## 事件类型
|
||||
|
||||
| 类型 | 用途 |
|
||||
|------|------|
|
||||
| `chat:message:delta` | 增量更新(追加文本) |
|
||||
| `chat:message` | 完全替换消息内容 |
|
||||
|
||||
```javascript
|
||||
// 增量更新
|
||||
{ type: "chat:message:delta", data: { content: "追加的内容" } }
|
||||
|
||||
// 完全替换
|
||||
{ type: "chat:message", data: { content: "完整的新内容" } }
|
||||
```
|
||||
|
||||
## 关键数据来源
|
||||
|
||||
| 数据 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `chat_id` | `body["chat_id"]` | 聊天会话 ID |
|
||||
| `message_id` | `body["id"]` | ⚠️ 注意:是 `body["id"]`,不是 `body["message_id"]` |
|
||||
| `token` | `localStorage.getItem('token')` | 用户认证 Token |
|
||||
| `originalContent` | 通过 API `GET /api/v1/chats/{chatId}` 获取 | 当前消息内容 |
|
||||
|
||||
## Python 端 API
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `__event_emitter__` | Callable | 发送状态/通知事件 |
|
||||
| `__event_call__` | Callable | 执行 JS 代码(用于可视化渲染) |
|
||||
| `__metadata__` | dict | 元数据(可能为 None) |
|
||||
| `body` | dict | 请求体,包含 messages、chat_id、id 等 |
|
||||
|
||||
### body 结构示例
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [...],
|
||||
"chat_id": "ac2633a3-5731-4944-98e3-bf9b3f0ef0ab",
|
||||
"id": "2e0bb7d4-dfc0-43d7-b028-fd9e06c6fdc8",
|
||||
"session_id": "bX30sHI8r4_CKxCdAAAL"
|
||||
}
|
||||
```
|
||||
|
||||
### 常用事件
|
||||
|
||||
```python
|
||||
# 发送状态更新
|
||||
await __event_emitter__({
|
||||
"type": "status",
|
||||
"data": {"description": "正在渲染...", "done": False}
|
||||
})
|
||||
|
||||
# 执行 JS 代码
|
||||
await __event_call__({
|
||||
"type": "execute",
|
||||
"data": {"code": "console.log('Hello from Python!')"}
|
||||
})
|
||||
|
||||
# 发送通知
|
||||
await __event_emitter__({
|
||||
"type": "notification",
|
||||
"data": {"type": "success", "content": "渲染完成!"}
|
||||
})
|
||||
```
|
||||
|
||||
## 适用场景
|
||||
|
||||
- **思维导图** (Markmap)
|
||||
- **信息图** (AntV Infographic)
|
||||
- **流程图** (Mermaid)
|
||||
- **数据图表** (ECharts, Chart.js)
|
||||
- **任何需要 JS 渲染的可视化内容**
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 竞态条件问题
|
||||
|
||||
⚠️ **多次快速点击会导致内容覆盖问题**
|
||||
|
||||
由于 API 调用是异步的,如果用户快速多次触发 Action:
|
||||
- 第一次点击:获取原始内容 A → 渲染 → 更新为 A+图片1
|
||||
- 第二次点击:可能获取到旧内容 A(第一次还没保存完)→ 更新为 A+图片2
|
||||
|
||||
结果:图片1 被覆盖丢失!
|
||||
|
||||
**解决方案**:
|
||||
- 添加防抖(debounce)机制
|
||||
- 使用锁/标志位防止重复执行
|
||||
- 或使用 `chat:message:delta` 增量更新
|
||||
|
||||
### 2. 不要直接修改 `body["messages"]`
|
||||
|
||||
消息更新应由 JS 通过 API 完成,确保获取最新内容。
|
||||
|
||||
### 3. f-string 限制
|
||||
|
||||
Python f-string 内不能直接使用反斜杠,需要将转义字符串预先处理:
|
||||
|
||||
```python
|
||||
# 转义 JSON 中的特殊字符
|
||||
body_json = json.dumps(data, ensure_ascii=False)
|
||||
escaped = body_json.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
|
||||
```
|
||||
|
||||
### 4. Data URI 大小限制
|
||||
|
||||
Base64 编码会增加约 33% 的体积,复杂图片可能导致消息过大。
|
||||
|
||||
### 5. 跨域问题
|
||||
|
||||
确保 CDN 资源支持 CORS。
|
||||
|
||||
### 6. API 权限
|
||||
|
||||
确保用户 token 有权限访问和更新目标消息。
|
||||
|
||||
## 与传统方式对比
|
||||
|
||||
| 特性 | 传统方式 (修改 body) | 新方式 (__event_call__) |
|
||||
|------|---------------------|------------------------|
|
||||
| 消息更新 | Python 直接修改 | JS 通过 API 更新 |
|
||||
| 原始内容 | Python 传递给 JS | JS 通过 API 获取 |
|
||||
| 灵活性 | 低 | 高 |
|
||||
| 实时性 | 一次性 | 可多次更新 |
|
||||
| 复杂度 | 简单 | 中等 |
|
||||
| 竞态风险 | 低 | ⚠️ 需要处理 |
|
||||
@@ -1,20 +1,21 @@
|
||||
# Export to Excel
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v0.3.4</span>
|
||||
<span class="version-badge">v0.3.7</span>
|
||||
|
||||
Export chat conversations to Excel spreadsheet format for analysis, archiving, and sharing.
|
||||
|
||||
|
||||
### What's New in v0.3.5
|
||||
- **Export Scope**: Added `EXPORT_SCOPE` valve to choose between exporting tables from the "Last Message" (default) or "All Messages".
|
||||
- **Smart Sheet Naming**: Automatically names sheets based on Markdown headers, AI titles (if enabled), or message index (e.g., `Msg1-Tab1`).
|
||||
- **Multiple Tables Support**: Improved handling of multiple tables within single or multiple messages.
|
||||
|
||||
## What's New in v0.3.4
|
||||
|
||||
- **Smart Filename Generation**: Now supports generating filenames based on Chat Title, AI Summary, or Markdown Headers.
|
||||
- **Configuration Options**: Added `TITLE_SOURCE` setting to control filename generation strategy.
|
||||
### What's New in v0.3.6
|
||||
- **OpenWebUI-Style Theme**: Modern dark header with light gray zebra striping for better readability.
|
||||
- **Zebra Striping**: Alternating row colors for improved visual scanning.
|
||||
- **Smart Data Type Conversion**: Automatically converts columns to numeric or datetime types.
|
||||
- **Full Cell Bold/Italic**: Supports Markdown bold/italic formatting in Excel.
|
||||
- **Partial Markdown Cleanup**: Removes partial Markdown symbols for cleaner output.
|
||||
- **Export Scope**: Choose between "Last Message" or "All Messages".
|
||||
- **Smart Sheet Naming**: Names sheets based on Markdown headers or message index.
|
||||
- **Smart Filename Generation**: Generates filenames based on Chat Title, AI Summary, or Markdown Headers.
|
||||
- **AI Title Generation**: Supports using a specific model (`MODEL_ID`) for title generation with progress notifications.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
# Export to Excel(导出到 Excel)
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v0.3.4</span>
|
||||
<span class="version-badge">v0.3.7</span>
|
||||
|
||||
将聊天记录导出为 Excel 表格,便于分析、归档和分享。
|
||||
|
||||
|
||||
### v0.3.5 更新内容
|
||||
- **导出范围**: 新增 `EXPORT_SCOPE` 配置项,可选择导出“最后一条消息”(默认)或“所有消息”中的表格。
|
||||
- **智能 Sheet 命名**: 根据 Markdown 标题、AI 标题(如启用)或消息索引(如 `消息1-表1`)自动命名 Sheet。
|
||||
- **多表格支持**: 优化了对单条或多条消息中包含多个表格的处理。
|
||||
|
||||
### v0.3.4 更新内容
|
||||
|
||||
- **智能文件名生成**:支持根据对话标题、AI 总结或 Markdown 标题生成文件名。
|
||||
- **配置选项**:新增 `TITLE_SOURCE` 设置,用于控制文件名生成策略。
|
||||
### v0.3.6 更新内容
|
||||
- **OpenWebUI 风格主题**:现代深灰表头,搭配浅灰斑马纹,提升可读性。
|
||||
- **斑马纹效果**:隔行变色,方便视觉扫描。
|
||||
- **智能数据类型转换**:自动将列转换为数字或日期类型。
|
||||
- **全单元格粗体/斜体**:支持 Markdown 粗体/斜体格式。
|
||||
- **部分 Markdown 清理**:移除部分 Markdown 符号,输出更整洁。
|
||||
- **导出范围**:可选择导出"最后一条消息"或"所有消息"。
|
||||
- **智能 Sheet 命名**:根据 Markdown 标题或消息索引命名 Sheet。
|
||||
- **智能文件名生成**:支持对话标题、AI 总结或 Markdown 标题生成文件名。
|
||||
- **AI 标题生成**:支持指定模型 (`MODEL_ID`) 生成标题,并提供生成进度通知。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Export to Word
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v0.1.0</span>
|
||||
<span class="version-badge">v0.4.1</span>
|
||||
|
||||
Export chat conversations to Word (.docx) with Markdown formatting, syntax highlighting, and smarter filenames.
|
||||
Export conversation to Word (.docx) with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,11 +13,17 @@ The Export to Word plugin converts chat messages from Markdown to a polished Wor
|
||||
|
||||
## Features
|
||||
|
||||
- :material-file-word-box: **DOCX Export**: Generate Word files with one click
|
||||
- :material-format-bold: **Rich Markdown Support**: Headings, bold/italic, lists, tables
|
||||
- :material-code-tags: **Syntax Highlighting**: Pygments-powered code blocks
|
||||
- :material-format-quote-close: **Styled Blockquotes**: Left-border gray quote styling
|
||||
- :material-file-document-outline: **Smart Filenames**: Configurable title source (Chat Title, AI Generated, or Markdown Title)
|
||||
- :material-file-word-box: **One-Click Export**: Adds an "Export to Word" action button to the chat.
|
||||
- :material-format-bold: **Markdown Conversion**: Converts Markdown syntax to Word formatting (headings, bold, italic, code, tables, lists).
|
||||
- :material-code-tags: **Syntax Highlighting**: Code blocks are highlighted with Pygments (supports 500+ languages).
|
||||
- :material-sigma: **Native Math Equations**: LaTeX math (`$$...$$`, `\[...\]`, `$...$`, `\(...\)`) converted to editable Word equations.
|
||||
- :material-graph: **Mermaid Diagrams**: Mermaid flowcharts and sequence diagrams rendered as images in the document.
|
||||
- :material-book-open-page-variant: **Citations & References**: Auto-generates a References section from OpenWebUI sources with clickable citation links.
|
||||
- :material-brain-off: **Reasoning Stripping**: Automatically removes AI thinking blocks (`<think>`, `<analysis>`) from exports.
|
||||
- :material-table: **Enhanced Tables**: Smart column widths, column alignment (`:---`, `---:`, `:---:`), header row repeat across pages.
|
||||
- :material-format-quote-close: **Blockquote Support**: Markdown blockquotes are rendered with left border and gray styling.
|
||||
- :material-translate: **Multi-language Support**: Properly handles both Chinese and English text.
|
||||
- :material-file-document-outline: **Smarter Filenames**: Configurable title source (Chat Title, AI Generated, or Markdown Title).
|
||||
|
||||
---
|
||||
|
||||
@@ -26,8 +32,36 @@ The Export to Word plugin converts chat messages from Markdown to a polished Wor
|
||||
You can configure the following settings via the **Valves** button in the plugin settings:
|
||||
|
||||
| Valve | Description | Default |
|
||||
| :------------- | :------------------------------------------------------------------------------------------ | :----------- |
|
||||
| :--- | :--- | :--- |
|
||||
| `TITLE_SOURCE` | Source for document title/filename. Options: `chat_title`, `ai_generated`, `markdown_title` | `chat_title` |
|
||||
| `MAX_EMBED_IMAGE_MB` | Maximum image size to embed into DOCX (MB). | `20` |
|
||||
| `UI_LANGUAGE` | User interface language. Options: `en` (English), `zh` (Chinese). | `en` |
|
||||
| `FONT_LATIN` | Font name for Latin characters. | `Times New Roman` |
|
||||
| `FONT_ASIAN` | Font name for Asian characters. | `SimSun` |
|
||||
| `FONT_CODE` | Font name for code blocks. | `Consolas` |
|
||||
| `TABLE_HEADER_COLOR` | Table header background color (Hex without #). | `F2F2F2` |
|
||||
| `TABLE_ZEBRA_COLOR` | Table alternating row background color (Hex without #). | `FBFBFB` |
|
||||
| `MERMAID_JS_URL` | URL for the Mermaid.js library. | `https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js` |
|
||||
| `MERMAID_JSZIP_URL` | URL for the JSZip library (required for DOCX manipulation). | `https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js` |
|
||||
| `MERMAID_PNG_SCALE` | Scale factor for Mermaid PNG generation (Resolution). | `3.0` |
|
||||
| `MERMAID_DISPLAY_SCALE` | Scale factor for Mermaid visual size in Word. | `1.0` |
|
||||
| `MERMAID_OPTIMIZE_LAYOUT` | Automatically convert LR (Left-Right) flowcharts to TD (Top-Down). | `False` |
|
||||
| `MERMAID_BACKGROUND` | Background color for Mermaid diagrams (e.g., `white`, `transparent`). | `transparent` |
|
||||
| `MERMAID_CAPTIONS_ENABLE` | Enable/disable figure captions for Mermaid diagrams. | `True` |
|
||||
| `MERMAID_CAPTION_STYLE` | Paragraph style name for Mermaid captions. | `Caption` |
|
||||
| `MERMAID_CAPTION_PREFIX` | Caption prefix label (e.g., 'Figure'). Empty = auto-detect based on language. | `""` |
|
||||
| `MATH_ENABLE` | Enable LaTeX math block conversion. | `True` |
|
||||
| `MATH_INLINE_DOLLAR_ENABLE` | Enable inline `$ ... $` math conversion. | `True` |
|
||||
|
||||
### User-Level Configuration (UserValves)
|
||||
|
||||
Users can override the following settings in their personal settings:
|
||||
- `TITLE_SOURCE`
|
||||
- `UI_LANGUAGE`
|
||||
- `FONT_LATIN`, `FONT_ASIAN`, `FONT_CODE`
|
||||
- `TABLE_HEADER_COLOR`, `TABLE_ZEBRA_COLOR`
|
||||
- `MERMAID_...` (Selected Mermaid settings)
|
||||
- `MATH_...` (Math settings)
|
||||
|
||||
---
|
||||
|
||||
@@ -47,31 +81,37 @@ You can configure the following settings via the **Valves** button in the plugin
|
||||
|
||||
---
|
||||
|
||||
## Supported Markdown
|
||||
## Supported Markdown Syntax
|
||||
|
||||
| Syntax | Word Result |
|
||||
| :---------------------------------- | :----------------------------- |
|
||||
| :--- | :--- |
|
||||
| `# Heading 1` to `###### Heading 6` | Heading levels 1-6 |
|
||||
| `**bold**` / `__bold__` | Bold text |
|
||||
| `*italic*` / `_italic_` | Italic text |
|
||||
| `**bold**` or `__bold__` | Bold text |
|
||||
| `*italic*` or `_italic_` | Italic text |
|
||||
| `***bold italic***` | Bold + Italic |
|
||||
| `` `inline code` `` | Monospace with gray background |
|
||||
| <code>``` code block ```</code> | Syntax-highlighted code block |
|
||||
| ` ``` code block ``` ` | **Syntax highlighted** code block |
|
||||
| `> blockquote` | Left-bordered gray italic text |
|
||||
| `[link](url)` | Blue underlined link |
|
||||
| `~~strikethrough~~` | Strikethrough |
|
||||
| `- item` / `* item` | Bullet list |
|
||||
| `[link](url)` | Blue underlined link text |
|
||||
| `~~strikethrough~~` | Strikethrough text |
|
||||
| `- item` or `* item` | Bullet list |
|
||||
| `1. item` | Numbered list |
|
||||
| Markdown tables | Grid table |
|
||||
| `---` / `***` | Horizontal rule |
|
||||
| Markdown tables | **Enhanced table** with smart widths |
|
||||
| `---` or `***` | Horizontal rule |
|
||||
| `$$LaTeX$$` or `\[LaTeX\]` | **Native Word equation** (display) |
|
||||
| `$LaTeX$` or `\(LaTeX\)` | **Native Word equation** (inline) |
|
||||
| ` ```mermaid ... ``` ` | **Mermaid diagram** as image |
|
||||
| `[1]` citation markers | **Clickable links** to References |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
!!! note "Prerequisites"
|
||||
- `python-docx==1.1.2` (document generation)
|
||||
- `Pygments>=2.15.0` (syntax highlighting, optional but recommended)
|
||||
- `python-docx==1.1.2` - Word document generation
|
||||
- `Pygments>=2.15.0` - Syntax highlighting
|
||||
- `latex2mathml` - LaTeX to MathML conversion
|
||||
- `mathml2omml` - MathML to Office Math (OMML) conversion
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Export to Word(导出为 Word)
|
||||
|
||||
<span class="category-badge action">Action</span>
|
||||
<span class="version-badge">v0.1.0</span>
|
||||
<span class="version-badge">v0.4.1</span>
|
||||
|
||||
将聊天记录按 Markdown 格式导出为 Word (.docx),支持语法高亮、引用样式和更智能的文件命名。
|
||||
将当前对话导出为完美格式的 Word 文档,支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用资料**以及**增强表格**渲染。
|
||||
|
||||
---
|
||||
|
||||
@@ -13,11 +13,17 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
|
||||
|
||||
## 功能特性
|
||||
|
||||
- :material-file-word-box: **DOCX 导出**:一键生成 Word 文件
|
||||
- :material-format-bold: **丰富 Markdown 支持**:标题、粗斜体、列表、表格
|
||||
- :material-code-tags: **语法高亮**:Pygments 驱动的代码块上色
|
||||
- :material-format-quote-close: **引用样式**:左侧边框的灰色斜体引用
|
||||
- :material-file-document-outline: **智能文件名**:可配置标题来源(对话标题、AI 生成或 Markdown 标题)
|
||||
- :material-file-word-box: **一键导出**:在聊天界面添加"导出为 Word"动作按钮。
|
||||
- :material-format-bold: **Markdown 转换**:将 Markdown 语法转换为 Word 格式(标题、粗体、斜体、代码、表格、列表)。
|
||||
- :material-code-tags: **代码语法高亮**:使用 Pygments 库为代码块添加语法高亮(支持 500+ 种语言)。
|
||||
- :material-sigma: **原生数学公式**:LaTeX 公式(`$$...$$`、`\[...\]`、`$...$`、`\(...\)`)转换为可编辑的 Word 公式。
|
||||
- :material-graph: **Mermaid 图表**:Mermaid 流程图和时序图渲染为文档中的图片。
|
||||
- :material-book-open-page-variant: **引用与参考**:自动从 OpenWebUI 来源生成参考资料章节,支持可点击的引用链接。
|
||||
- :material-brain-off: **移除思考过程**:自动移除 AI 思考块(`<think>`、`<analysis>`)。
|
||||
- :material-table: **增强表格**:智能列宽、列对齐(`:---`、`---:`、`:---:`)、表头跨页重复。
|
||||
- :material-format-quote-close: **引用块支持**:Markdown 引用块渲染为带左侧边框的灰色斜体样式。
|
||||
- :material-translate: **多语言支持**:正确处理中文和英文文本,无乱码问题。
|
||||
- :material-file-document-outline: **智能文件名**:可配置标题来源(对话标题、AI 生成或 Markdown 标题)。
|
||||
|
||||
---
|
||||
|
||||
@@ -26,8 +32,36 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
|
||||
您可以通过插件设置中的 **Valves** 按钮配置以下选项:
|
||||
|
||||
| Valve | 说明 | 默认值 |
|
||||
| :------------- | :--------------------------------------------------------------------------------------------------------------- | :----------- |
|
||||
| `TITLE_SOURCE` | 文档标题/文件名的来源。选项:`chat_title` (对话标题), `ai_generated` (AI 生成), `markdown_title` (Markdown 标题) | `chat_title` |
|
||||
| :--- | :--- | :--- |
|
||||
| `文档标题来源` | 文档标题/文件名的来源。选项:`chat_title` (对话标题), `ai_generated` (AI 生成), `markdown_title` (Markdown 标题) | `chat_title` |
|
||||
| `最大嵌入图片大小MB` | 嵌入图片的最大大小 (MB)。 | `20` |
|
||||
| `界面语言` | 界面语言。选项:`en` (英语), `zh` (中文)。 | `zh` |
|
||||
| `英文字体` | 英文字体名称。 | `Calibri` |
|
||||
| `中文字体` | 中文字体名称。 | `SimSun` |
|
||||
| `代码字体` | 代码字体名称。 | `Consolas` |
|
||||
| `表头背景色` | 表头背景色(十六进制,不带#)。 | `F2F2F2` |
|
||||
| `表格隔行背景色` | 表格隔行背景色(十六进制,不带#)。 | `FBFBFB` |
|
||||
| `Mermaid_JS地址` | Mermaid.js 库的 URL。 | `https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js` |
|
||||
| `JSZip库地址` | JSZip 库的 URL(用于 DOCX 操作)。 | `https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js` |
|
||||
| `Mermaid_PNG缩放比例` | Mermaid PNG 生成缩放比例(分辨率)。 | `3.0` |
|
||||
| `Mermaid显示比例` | Mermaid 在 Word 中的显示比例(视觉大小)。 | `1.0` |
|
||||
| `Mermaid布局优化` | 优化 Mermaid 布局: 自动将 LR (左右) 转换为 TD (上下)。 | `False` |
|
||||
| `Mermaid背景色` | Mermaid 图表背景色(如 `white`, `transparent`)。 | `transparent` |
|
||||
| `启用Mermaid图注` | 启用/禁用 Mermaid 图表的图注。 | `True` |
|
||||
| `Mermaid图注样式` | Mermaid 图注的段落样式名称。 | `Caption` |
|
||||
| `Mermaid图注前缀` | 图注前缀(如 '图')。留空则根据语言自动检测。 | `""` |
|
||||
| `启用数学公式` | 启用 LaTeX 数学公式块转换。 | `True` |
|
||||
| `启用行内公式` | 启用行内 `$ ... $` 数学公式转换。 | `True` |
|
||||
|
||||
### 用户级配置 (UserValves)
|
||||
|
||||
用户可以在个人设置中覆盖以下配置:
|
||||
- `文档标题来源`
|
||||
- `界面语言`
|
||||
- `英文字体`, `中文字体`, `代码字体`
|
||||
- `表头背景色`, `表格隔行背景色`
|
||||
- `Mermaid_...` (部分 Mermaid 设置)
|
||||
- `启用数学公式`, `启用行内公式`
|
||||
|
||||
---
|
||||
|
||||
@@ -47,10 +81,10 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
|
||||
|
||||
---
|
||||
|
||||
## 支持的 Markdown
|
||||
## 支持的 Markdown 语法
|
||||
|
||||
| 语法 | Word 效果 |
|
||||
| :-------------------------- | :------------------ |
|
||||
| :--- | :--- |
|
||||
| `# 标题1` 到 `###### 标题6` | 标题级别 1-6 |
|
||||
| `**粗体**` / `__粗体__` | 粗体文本 |
|
||||
| `*斜体*` / `_斜体_` | 斜体文本 |
|
||||
@@ -62,8 +96,12 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
|
||||
| `~~删除线~~` | 删除线 |
|
||||
| `- 项目` / `* 项目` | 无序列表 |
|
||||
| `1. 项目` | 有序列表 |
|
||||
| Markdown 表格 | 带边框表格 |
|
||||
| Markdown 表格 | **增强表格**(智能列宽) |
|
||||
| `---` / `***` | 水平分割线 |
|
||||
| `$$LaTeX$$` 或 `\[LaTeX\]` | **原生 Word 公式**(块级) |
|
||||
| `$LaTeX$` 或 `\(LaTeX\)` | **原生 Word 公式**(行内) |
|
||||
| ` ```mermaid ... ``` ` | **Mermaid 图表**(图片形式) |
|
||||
| `[1]` 引用标记 | **可点击链接**到参考资料 |
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +109,9 @@ Export to Word 插件会把聊天消息从 Markdown 转成精致的 Word 文档
|
||||
|
||||
!!! note "前置条件"
|
||||
- `python-docx==1.1.2`(文档生成)
|
||||
- `Pygments>=2.15.0`(语法高亮,建议安装)
|
||||
- `Pygments>=2.15.0`(语法高亮)
|
||||
- `latex2mathml`(LaTeX 转 MathML)
|
||||
- `mathml2omml`(MathML 转 Office Math)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -53,17 +53,17 @@ Actions are interactive plugins that:
|
||||
|
||||
Export chat conversations to Excel spreadsheet format for analysis and archiving.
|
||||
|
||||
**Version:** 0.3.5
|
||||
**Version:** 0.3.7
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](export-to-excel.md)
|
||||
|
||||
- :material-file-word-box:{ .lg .middle } **Export to Word**
|
||||
- :material-file-word-box:{ .lg .middle } **Export to Word (Enhanced Formatting)**
|
||||
|
||||
---
|
||||
|
||||
Export chat content as Word (.docx) with Markdown formatting and syntax highlighting.
|
||||
Export the current conversation to a formatted Word doc with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
|
||||
|
||||
**Version:** 0.1.0
|
||||
**Version:** 0.4.1
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](export-to-word.md)
|
||||
|
||||
@@ -77,6 +77,16 @@ Actions are interactive plugins that:
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](summary.md)
|
||||
|
||||
- :material-image-text:{ .lg .middle } **Infographic to Markdown**
|
||||
|
||||
---
|
||||
|
||||
AI-powered infographic generator that renders SVG and embeds it as Markdown Data URL image.
|
||||
|
||||
**Version:** 1.0.0
|
||||
|
||||
[:octicons-arrow-right-24: Documentation](infographic-markdown.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -53,17 +53,17 @@ Actions 是交互式插件,能够:
|
||||
|
||||
将聊天记录导出为 Excel 电子表格,方便分析或归档。
|
||||
|
||||
**版本:** 0.3.4
|
||||
**版本:** 0.3.7
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](export-to-excel.md)
|
||||
|
||||
- :material-file-word-box:{ .lg .middle } **Export to Word**
|
||||
- :material-file-word-box:{ .lg .middle } **Word 导出 (格式增强)**
|
||||
|
||||
---
|
||||
|
||||
将聊天内容按 Markdown 格式导出为 Word (.docx),支持语法高亮。
|
||||
将当前对话导出为完美格式的 Word 文档,支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用资料**以及**增强表格**渲染。
|
||||
|
||||
**版本:** 0.1.0
|
||||
**版本:** 0.4.1
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](export-to-word.md)
|
||||
|
||||
@@ -77,6 +77,16 @@ Actions 是交互式插件,能够:
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](summary.md)
|
||||
|
||||
- :material-image-text:{ .lg .middle } **信息图转 Markdown**
|
||||
|
||||
---
|
||||
|
||||
AI 驱动的信息图生成器,渲染 SVG 并以 Markdown Data URL 图片嵌入。
|
||||
|
||||
**版本:** 1.0.0
|
||||
|
||||
[:octicons-arrow-right-24: 查看文档](infographic-markdown.zh.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
120
docs/plugins/actions/infographic-markdown.md
Normal file
120
docs/plugins/actions/infographic-markdown.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Infographic to Markdown
|
||||
|
||||
> **Version:** 1.0.0 | **Author:** Fu-Jie
|
||||
|
||||
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.
|
||||
|
||||
### Key Features
|
||||
|
||||
- :robot: **AI-Powered**: Automatically analyzes text and selects the best infographic template
|
||||
- :bar_chart: **Multiple Templates**: Supports 18+ infographic templates (lists, charts, comparisons, etc.)
|
||||
- :framed_picture: **Self-Contained**: SVG/PNG embedded as Data URL, no external dependencies
|
||||
- :memo: **Markdown Native**: Results are pure Markdown images, compatible everywhere
|
||||
- :arrows_counterclockwise: **API Writeback**: Updates message content via REST API for persistence
|
||||
|
||||
### How It Works
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User triggers action] --> B[Python extracts message content]
|
||||
B --> C[LLM generates Infographic syntax]
|
||||
C --> D[Frontend JS loads AntV library]
|
||||
D --> E[Render SVG offscreen]
|
||||
E --> F[Export to Data URL]
|
||||
F --> G[Update message via API]
|
||||
G --> H[Display as Markdown image]
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download `infographic_markdown.py` (English) or `infographic_markdown_cn.py` (Chinese)
|
||||
2. Navigate to **Admin Panel** → **Settings** → **Functions**
|
||||
3. Upload the file and configure settings
|
||||
4. Use the action button in chat messages
|
||||
|
||||
## Configuration
|
||||
|
||||
| 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 |
|
||||
|
||||
## Usage Example
|
||||
|
||||
1. Generate some text content in the chat (or have the AI generate it)
|
||||
2. Click the **📊 Infographic to Markdown** action button
|
||||
3. Wait for AI analysis and SVG rendering
|
||||
4. The infographic will be embedded as a Markdown image
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Data URL Embedding
|
||||
|
||||
The plugin converts SVG graphics to Base64-encoded Data URLs:
|
||||
|
||||
```javascript
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = "data:image/svg+xml;base64," + base64;
|
||||
const markdownImage = ``;
|
||||
```
|
||||
|
||||
### AntV toDataURL API
|
||||
|
||||
```javascript
|
||||
// Export as SVG (recommended)
|
||||
const svgUrl = await instance.toDataURL({
|
||||
type: 'svg',
|
||||
embedResources: true
|
||||
});
|
||||
|
||||
// Export as PNG
|
||||
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)
|
||||
120
docs/plugins/actions/infographic-markdown.zh.md
Normal file
120
docs/plugins/actions/infographic-markdown.zh.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 信息图转 Markdown
|
||||
|
||||
> **版本:** 1.0.0 | **作者:** Fu-Jie
|
||||
|
||||
AI 驱动的信息图生成器,在前端渲染 SVG 并以 Data URL 图片格式直接嵌入到 Markdown 中。
|
||||
|
||||
## 概述
|
||||
|
||||
这个插件结合了 AI 文本分析能力和 AntV Infographic 可视化引擎,生成精美的信息图并以 Markdown 图片格式直接嵌入到聊天消息中。
|
||||
|
||||
### 主要特性
|
||||
|
||||
- :robot: **AI 驱动**: 自动分析文本并选择最佳的信息图模板
|
||||
- :bar_chart: **多种模板**: 支持 18+ 种信息图模板(列表、图表、对比等)
|
||||
- :framed_picture: **自包含**: SVG/PNG 以 Data URL 嵌入,无外部依赖
|
||||
- :memo: **Markdown 原生**: 结果是纯 Markdown 图片,兼容任何平台
|
||||
- :arrows_counterclockwise: **API 回写**: 通过 REST API 更新消息内容实现持久化
|
||||
|
||||
### 工作原理
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户触发动作] --> B[Python 提取消息内容]
|
||||
B --> C[LLM 生成 Infographic 语法]
|
||||
C --> D[前端 JS 加载 AntV 库]
|
||||
D --> E[离屏渲染 SVG]
|
||||
E --> F[导出为 Data URL]
|
||||
F --> G[通过 API 更新消息]
|
||||
G --> H[显示为 Markdown 图片]
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
1. 下载 `infographic_markdown.py`(英文版)或 `infographic_markdown_cn.py`(中文版)
|
||||
2. 进入 **管理面板** → **设置** → **功能**
|
||||
3. 上传文件并配置设置
|
||||
4. 在聊天消息中使用动作按钮
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 参数 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `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` | 面积图 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
1. 在聊天中生成一些文本内容(或让 AI 生成)
|
||||
2. 点击 **📊 信息图转 Markdown** 动作按钮
|
||||
3. 等待 AI 分析和 SVG 渲染
|
||||
4. 信息图将以 Markdown 图片形式嵌入
|
||||
|
||||
## 技术细节
|
||||
|
||||
### Data URL 嵌入
|
||||
|
||||
插件将 SVG 图形转换为 Base64 编码的 Data URL:
|
||||
|
||||
```javascript
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const base64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = "data:image/svg+xml;base64," + base64;
|
||||
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)
|
||||
@@ -315,7 +315,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "Assistant" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} Message {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
return body # Or handle error
|
||||
|
||||
@@ -326,7 +326,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "助手" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} 消息 {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
return body # 或者处理错误
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
# Export to Word
|
||||
|
||||
Export current conversation from Markdown to Word (.docx) with **syntax highlighting**, **blockquote support**, and smarter filenames.
|
||||
Export conversation to Word (.docx) with **syntax highlighting**, **native math equations**, **Mermaid diagrams**, **citations**, and **enhanced table formatting**.
|
||||
|
||||
## Features
|
||||
|
||||
- **One-Click Export**: Adds an "Export to Word" action button to the chat.
|
||||
- **Markdown Conversion**: Converts Markdown syntax to Word formatting (headings, bold, italic, code, tables, lists).
|
||||
- **Syntax Highlighting**: Code blocks are highlighted with Pygments (supports 500+ languages).
|
||||
- **Native Math Equations**: LaTeX math (`$$...$$`, `\[...\]`, `$...$`, `\(...\)`) converted to editable Word equations.
|
||||
- **Mermaid Diagrams**: Mermaid flowcharts and sequence diagrams rendered as images in the document.
|
||||
- **Citations & References**: Auto-generates a References section from OpenWebUI sources with clickable citation links.
|
||||
- **Reasoning Stripping**: Automatically removes AI thinking blocks (`<think>`, `<analysis>`) from exports.
|
||||
- **Enhanced Tables**: Smart column widths, column alignment (`:---`, `---:`, `:---:`), header row repeat across pages.
|
||||
- **Blockquote Support**: Markdown blockquotes are rendered with left border and gray styling.
|
||||
- **Multi-language Support**: Properly handles both Chinese and English text without garbled characters.
|
||||
- **Multi-language Support**: Properly handles both Chinese and English text.
|
||||
- **Smarter Filenames**: Configurable title source (Chat Title, AI Generated, or Markdown Title).
|
||||
|
||||
## Configuration
|
||||
@@ -19,11 +24,29 @@ You can configure the following settings via the **Valves** button in the plugin
|
||||
- `chat_title`: Use the conversation title (default).
|
||||
- `ai_generated`: Use AI to generate a short title based on the content.
|
||||
- `markdown_title`: Extract the first h1/h2 heading from the Markdown content.
|
||||
- **MAX_EMBED_IMAGE_MB**: Maximum image size to embed into DOCX (MB). Default: `20`.
|
||||
- **UI_LANGUAGE**: User interface language, supports `en` (English) and `zh` (Chinese). Default: `en`.
|
||||
- **FONT_LATIN**: Font name for Latin characters. Default: `Times New Roman`.
|
||||
- **FONT_ASIAN**: Font name for Asian characters. Default: `SimSun`.
|
||||
- **FONT_CODE**: Font name for code blocks. Default: `Consolas`.
|
||||
- **TABLE_HEADER_COLOR**: Table header background color (Hex without #). Default: `F2F2F2`.
|
||||
- **TABLE_ZEBRA_COLOR**: Table alternating row background color (Hex without #). Default: `FBFBFB`.
|
||||
- **MERMAID_JS_URL**: URL for the Mermaid.js library.
|
||||
- **MERMAID_JSZIP_URL**: URL for the JSZip library (required for DOCX manipulation).
|
||||
- **MERMAID_PNG_SCALE**: Scale factor for Mermaid PNG generation (Resolution). Default: `3.0`.
|
||||
- **MERMAID_DISPLAY_SCALE**: Scale factor for Mermaid visual size in Word. Default: `1.0`.
|
||||
- **MERMAID_OPTIMIZE_LAYOUT**: Automatically convert LR (Left-Right) flowcharts to TD (Top-Down). Default: `False`.
|
||||
- **MERMAID_BACKGROUND**: Background color for Mermaid diagrams (e.g., `white`, `transparent`). Default: `transparent`.
|
||||
- **MERMAID_CAPTIONS_ENABLE**: Enable/disable figure captions for Mermaid diagrams. Default: `True`.
|
||||
- **MERMAID_CAPTION_STYLE**: Paragraph style name for Mermaid captions. Default: `Caption`.
|
||||
- **MERMAID_CAPTION_PREFIX**: Caption prefix label (e.g., 'Figure'). Empty = auto-detect based on language.
|
||||
- **MATH_ENABLE**: Enable LaTeX math block conversion (`\[...\]` and `$$...$$`). Default: `True`.
|
||||
- **MATH_INLINE_DOLLAR_ENABLE**: Enable inline `$ ... $` math conversion. Default: `True`.
|
||||
|
||||
## Supported Markdown Syntax
|
||||
|
||||
| Syntax | Word Result |
|
||||
| :---------------------------------- | :-------------------------------- |
|
||||
| :---------------------------------- | :------------------------------------ |
|
||||
| `# Heading 1` to `###### Heading 6` | Heading levels 1-6 |
|
||||
| `**bold**` or `__bold__` | Bold text |
|
||||
| `*italic*` or `_italic_` | Italic text |
|
||||
@@ -35,8 +58,12 @@ You can configure the following settings via the **Valves** button in the plugin
|
||||
| `~~strikethrough~~` | Strikethrough text |
|
||||
| `- item` or `* item` | Bullet list |
|
||||
| `1. item` | Numbered list |
|
||||
| Markdown tables | Table with grid |
|
||||
| Markdown tables | **Enhanced table** with smart widths |
|
||||
| `---` or `***` | Horizontal rule |
|
||||
| `$$LaTeX$$` or `\[LaTeX\]` | **Native Word equation** (display) |
|
||||
| `$LaTeX$` or `\(LaTeX\)` | **Native Word equation** (inline) |
|
||||
| ` ```mermaid ... ``` ` | **Mermaid diagram** as image |
|
||||
| `[1]` citation markers | **Clickable links** to References |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -44,19 +71,14 @@ You can configure the following settings via the **Valves** button in the plugin
|
||||
2. In any chat, click the "Export to Word" button.
|
||||
3. The .docx file will be automatically downloaded to your device.
|
||||
|
||||
|
||||
### Notes
|
||||
|
||||
- Title detection only considers h1/h2 headings.
|
||||
- If the request carries `chat_id` (body or metadata), the plugin will fetch the chat title from the database when the body lacks one.
|
||||
- Default fonts: Times New Roman (en), SimSun/SimHei (zh), Consolas (code).
|
||||
|
||||
### Requirements
|
||||
## Requirements
|
||||
|
||||
- `python-docx==1.1.2` - Word document generation
|
||||
- `Pygments>=2.15.0` - Syntax highlighting (optional but recommended)
|
||||
- `Pygments>=2.15.0` - Syntax highlighting
|
||||
- `latex2mathml` - LaTeX to MathML conversion
|
||||
- `mathml2omml` - MathML to Office Math (OMML) conversion
|
||||
|
||||
Both are declared in the plugin docstring; ensure they are installed in your environment.
|
||||
All dependencies are declared in the plugin docstring.
|
||||
|
||||
## Font Configuration
|
||||
|
||||
@@ -64,6 +86,40 @@ Both are declared in the plugin docstring; ensure they are installed in your env
|
||||
- **Chinese Text**: SimSun (宋体) for body, SimHei (黑体) for headings
|
||||
- **Code**: Consolas
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.4.0
|
||||
|
||||
- **Multi-language Support**: Added UI language switching (English/Chinese) with localized messages.
|
||||
- **Font & Style Configuration**: Customizable fonts for Latin/Asian text and code, plus table colors.
|
||||
- **Mermaid Enhancements**:
|
||||
- Hybrid client-side rendering (SVG+PNG) for better clarity and compatibility.
|
||||
- Configurable background color, fixing issues in dark mode.
|
||||
- Added error boundaries to prevent export failures on render errors.
|
||||
- **Performance**: Real-time progress updates for large document exports.
|
||||
- **Bug Fixes**:
|
||||
- Fixed parsing errors in Markdown tables containing code blocks or links.
|
||||
- Fixed parsing issues with underscores (`_`), asterisks (`*`), and tildes (`~`) used as long separators.
|
||||
- Enhanced error handling for image embedding.
|
||||
|
||||
### v0.3.0
|
||||
|
||||
- **Mermaid Diagrams**: Native support for rendering Mermaid diagrams as images in Word.
|
||||
- **Native Math**: Converts LaTeX equations to native Office MathML for editable equations.
|
||||
- **Citations**: Automatic bibliography generation and citation linking.
|
||||
- **Reasoning Removal**: Option to strip `<think>` blocks from the output.
|
||||
- **Table Enhancements**: Improved table formatting with smart column widths.
|
||||
|
||||
### v0.2.0
|
||||
- Added native math equation support (LaTeX → OMML)
|
||||
- Added Mermaid diagram rendering
|
||||
- Added citations and references section generation
|
||||
- Added automatic reasoning block stripping
|
||||
- Enhanced table formatting with smart column widths and alignment
|
||||
|
||||
### v0.1.1
|
||||
- Initial release with basic Markdown to Word conversion
|
||||
|
||||
## Author
|
||||
|
||||
Fu-Jie
|
||||
|
||||
@@ -1,29 +1,52 @@
|
||||
# 导出为 Word
|
||||
|
||||
将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持**代码语法高亮**、**引用块样式**和更智能的文件命名。
|
||||
将对话导出为 Word (.docx),支持**代码语法高亮**、**原生数学公式**、**Mermaid 图表**、**引用参考**和**增强表格格式**。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **一键导出**:在聊天界面添加“导出为 Word”动作按钮。
|
||||
- **一键导出**:在聊天界面添加"导出为 Word"动作按钮。
|
||||
- **Markdown 转换**:将 Markdown 语法转换为 Word 格式(标题、粗体、斜体、代码、表格、列表)。
|
||||
- **代码语法高亮**:使用 Pygments 库为代码块添加语法高亮(支持 500+ 种语言)。
|
||||
- **引用块支持**:Markdown 引用块会渲染为带左侧边框的灰色斜体样式。
|
||||
- **原生数学公式**:LaTeX 公式(`$$...$$`、`\[...\]`、`$...$`、`\(...\)`)转换为可编辑的 Word 公式。
|
||||
- **Mermaid 图表**:Mermaid 流程图和时序图渲染为文档中的图片。
|
||||
- **引用与参考**:自动从 OpenWebUI 来源生成参考资料章节,支持可点击的引用链接。
|
||||
- **移除思考过程**:自动移除 AI 思考块(`<think>`、`<analysis>`)。
|
||||
- **增强表格**:智能列宽、列对齐(`:---`、`---:`、`:---:`)、表头跨页重复。
|
||||
- **引用块支持**:Markdown 引用块渲染为带左侧边框的灰色斜体样式。
|
||||
- **多语言支持**:正确处理中文和英文文本,无乱码问题。
|
||||
- **更智能的文件名**:可配置标题来源(对话标题、AI 生成或 Markdown 标题)。
|
||||
- **智能文件名**:可配置标题来源(对话标题、AI 生成或 Markdown 标题)。
|
||||
|
||||
## 配置 (Configuration)
|
||||
## 配置
|
||||
|
||||
您可以通过插件设置中的 **Valves** 按钮配置以下选项:
|
||||
|
||||
- **TITLE_SOURCE**:选择文档标题/文件名的生成方式。
|
||||
- **文档标题来源**:选择文档标题/文件名的生成方式。
|
||||
- `chat_title`:使用对话标题(默认)。
|
||||
- `ai_generated`:使用 AI 根据内容生成简短标题。
|
||||
- `markdown_title`:从 Markdown 内容中提取第一个一级或二级标题。
|
||||
- **最大嵌入图片大小MB**:嵌入图片的最大大小 (MB)。默认:`20`。
|
||||
- **界面语言**:界面语言,支持 `en` (英语) 和 `zh` (中文)。默认:`zh`。
|
||||
- **英文字体**:英文字体名称。默认:`Calibri`。
|
||||
- **中文字体**:中文字体名称。默认:`SimSun`。
|
||||
- **代码字体**:代码字体名称。默认:`Consolas`。
|
||||
- **表头背景色**:表头背景色(十六进制,不带#)。默认:`F2F2F2`。
|
||||
- **表格隔行背景色**:表格隔行背景色(十六进制,不带#)。默认:`FBFBFB`。
|
||||
- **Mermaid_JS地址**:Mermaid.js 库的 URL。
|
||||
- **JSZip库地址**:JSZip 库的 URL(用于 DOCX 操作)。
|
||||
- **Mermaid_PNG缩放比例**:Mermaid PNG 生成缩放比例(分辨率)。默认:`3.0`。
|
||||
- **Mermaid显示比例**:Mermaid 在 Word 中的显示比例(视觉大小)。默认:`1.0`。
|
||||
- **Mermaid布局优化**:自动将 LR(左右)流程图转换为 TD(上下)。默认:`False`。
|
||||
- **Mermaid背景色**:Mermaid 图表背景色(如 `white`, `transparent`)。默认:`transparent`。
|
||||
- **启用Mermaid图注**:启用/禁用 Mermaid 图表的图注。默认:`True`。
|
||||
- **Mermaid图注样式**:Mermaid 图注的段落样式名称。默认:`Caption`。
|
||||
- **Mermaid图注前缀**:图注前缀(如 '图')。留空则根据语言自动检测。
|
||||
- **启用数学公式**:启用 LaTeX 数学公式块转换(`\[...\]` 和 `$$...$$`)。默认:`True`。
|
||||
- **启用行内公式**:启用行内 `$ ... $` 数学公式转换。默认:`True`。
|
||||
|
||||
## 支持的 Markdown 语法
|
||||
|
||||
| 语法 | Word 效果 |
|
||||
| :-------------------------- | :----------------------- |
|
||||
| :---------------------------- | :-------------------------------- |
|
||||
| `# 标题1` 到 `###### 标题6` | 标题级别 1-6 |
|
||||
| `**粗体**` 或 `__粗体__` | 粗体文本 |
|
||||
| `*斜体*` 或 `_斜体_` | 斜体文本 |
|
||||
@@ -35,8 +58,12 @@
|
||||
| `~~删除线~~` | 删除线文本 |
|
||||
| `- 项目` 或 `* 项目` | 无序列表 |
|
||||
| `1. 项目` | 有序列表 |
|
||||
| Markdown 表格 | 带边框表格 |
|
||||
| Markdown 表格 | **增强表格**(智能列宽) |
|
||||
| `---` 或 `***` | 水平分割线 |
|
||||
| `$$LaTeX$$` 或 `\[LaTeX\]` | **原生 Word 公式**(块级) |
|
||||
| `$LaTeX$` 或 `\(LaTeX\)` | **原生 Word 公式**(行内) |
|
||||
| ` ```mermaid ... ``` ` | **Mermaid 图表**(图片形式) |
|
||||
| `[1]` 引用标记 | **可点击链接**到参考资料 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
@@ -44,18 +71,14 @@
|
||||
2. 在任意对话中,点击"导出为 Word"按钮。
|
||||
3. .docx 文件将自动下载到你的设备。
|
||||
|
||||
### 说明
|
||||
|
||||
- 标题检测仅考虑一级/二级标题(h1/h2)。
|
||||
- 若请求体或 metadata 提供 `chat_id`,当正文缺少标题时会从数据库查询对话标题。
|
||||
- 默认字体:英文 Times New Roman,中文宋体/黑体,代码 Consolas。
|
||||
|
||||
### 依赖
|
||||
## 依赖
|
||||
|
||||
- `python-docx==1.1.2` - Word 文档生成
|
||||
- `Pygments>=2.15.0` - 语法高亮(可选但建议安装)
|
||||
- `Pygments>=2.15.0` - 语法高亮
|
||||
- `latex2mathml` - LaTeX 转 MathML
|
||||
- `mathml2omml` - MathML 转 Office Math (OMML)
|
||||
|
||||
两者已在插件文档字符串中声明,请确保环境已安装。
|
||||
所有依赖已在插件文档字符串中声明。
|
||||
|
||||
## 字体配置
|
||||
|
||||
@@ -63,6 +86,44 @@
|
||||
- **中文文本**:宋体(正文)、黑体(标题)
|
||||
- **代码**:Consolas
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- **中文参数名**: 将插件配置项名称和描述全部汉化,提升中文用户体验。
|
||||
|
||||
### v0.4.0
|
||||
|
||||
- **多语言支持**: 新增界面语言切换(中文/英文),提示信息更友好。
|
||||
- **字体与样式配置**: 支持自定义中英文字体、代码字体以及表格颜色。
|
||||
- **Mermaid 增强**:
|
||||
- 客户端混合渲染(SVG+PNG),提高清晰度与兼容性。
|
||||
- 支持背景色配置,修复深色模式下的显示问题。
|
||||
- 增加错误边界,渲染失败时显示提示而非中断导出。
|
||||
- **性能优化**: 导出大型文档时提供实时进度反馈。
|
||||
- **Bug 修复**:
|
||||
- 修复 Markdown 表格中包含代码块或链接时的解析错误。
|
||||
- 修复下划线(`_`)、星号(`*`)、波浪号(`~`)作为长分隔符时的解析问题。
|
||||
- 增强图片嵌入的错误处理。
|
||||
|
||||
### v0.3.0
|
||||
|
||||
- **Mermaid 图表**: 原生支持将 Mermaid 图表渲染为 Word 中的图片。
|
||||
- **原生公式**: 将 LaTeX 公式转换为原生 Office MathML,支持在 Word 中编辑。
|
||||
- **引用参考**: 自动生成参考文献列表并链接引用。
|
||||
- **移除推理**: 选项支持从输出中移除 `<think>` 推理块。
|
||||
- **表格增强**: 改进表格格式,支持智能列宽。
|
||||
|
||||
### v0.2.0
|
||||
- 新增原生数学公式支持(LaTeX → OMML)
|
||||
- 新增 Mermaid 图表渲染
|
||||
- 新增引用与参考资料章节生成
|
||||
- 新增自动移除 AI 思考块
|
||||
- 增强表格格式(智能列宽、对齐)
|
||||
|
||||
### v0.1.1
|
||||
- 初始版本,支持基本 Markdown 转 Word
|
||||
|
||||
## 作者
|
||||
|
||||
Fu-Jie
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2803
plugins/actions/export_to_docx/export_to_word_cn.py
Normal file
2803
plugins/actions/export_to_docx/export_to_word_cn.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,882 +0,0 @@
|
||||
"""
|
||||
title: 导出为 Word
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.1.0
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB3aWR0aD0iMjQiCiAgaGVpZ2h0PSIyNCIKICB2aWV3Qm94PSIwIDAgMjQgMjQiCiAgZmlsbD0ibm9uZSIKICBzdHJva2U9ImN1cnJlbnRDb2xvciIKICBzdHJva2Utd2lkdGg9IjIiCiAgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgogIHN0cm9rZS1saW5lam9pbj0icm91bmQiCj4KICA8cGF0aCBkPSJNNiAyMmEyIDIgMCAwIDEtMi0yVjRhMiAyIDAgMCAxIDItMmg4YTIuNCAyLjQgMCAwIDEgMS43MDQuNzA2bDMuNTg4IDMuNTg4QTIuNCAyLjQgMCAwIDEgMjAgOHYxMmEyIDIgMCAwIDEtMiAyeiIgLz4KICA8cGF0aCBkPSJNMTQgMnY1YTEgMSAwIDAgMCAxIDFoNSIgLz4KICA8cGF0aCBkPSJNMTAgOUg4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxM0g4IiAvPgogIDxwYXRoIGQ9Ik0xNiAxN0g4IiAvPgo8L3N2Zz4K
|
||||
requirements: python-docx==1.1.2, Pygments>=2.15.0
|
||||
description: 将当前对话内容从 Markdown 转换并导出为 Word (.docx) 文件,支持代码语法高亮和引用块。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Callable, Awaitable, Any, List, Tuple
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Inches, RGBColor, Cm
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.style import WD_STYLE_TYPE
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Pygments for syntax highlighting
|
||||
try:
|
||||
from pygments import lex
|
||||
from pygments.lexers import get_lexer_by_name, TextLexer
|
||||
from pygments.token import Token
|
||||
|
||||
PYGMENTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYGMENTS_AVAILABLE = False
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
TITLE_SOURCE: str = Field(
|
||||
default="chat_title",
|
||||
description="标题来源: 'chat_title' (对话标题), 'ai_generated' (AI 生成), 'markdown_title' (Markdown 标题)",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def _send_notification(self, emitter: Callable, type: str, content: str):
|
||||
await emitter(
|
||||
{"type": "notification", "data": {"type": type, "content": content}}
|
||||
)
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__=None,
|
||||
__event_emitter__=None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__request__: Optional[Any] = None,
|
||||
):
|
||||
logger.info(f"action:{__name__}")
|
||||
|
||||
# 解析用户信息
|
||||
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")
|
||||
|
||||
if __event_emitter__:
|
||||
last_assistant_message = body["messages"][-1]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "正在转换为 Word 文档...", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
message_content = last_assistant_message["content"]
|
||||
|
||||
if not message_content or not message_content.strip():
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", "没有找到可导出的内容!"
|
||||
)
|
||||
return
|
||||
|
||||
# 生成文件名
|
||||
title = ""
|
||||
chat_id = self.extract_chat_id(body, __metadata__)
|
||||
|
||||
# 直接通过 chat_id 获取标题,因为 body 中通常不包含标题
|
||||
chat_title = ""
|
||||
if chat_id:
|
||||
chat_title = await self.fetch_chat_title(chat_id, user_id)
|
||||
|
||||
# 根据配置决定文件名使用的标题
|
||||
if (
|
||||
self.valves.TITLE_SOURCE == "chat_title"
|
||||
or not self.valves.TITLE_SOURCE
|
||||
):
|
||||
title = chat_title
|
||||
elif self.valves.TITLE_SOURCE == "markdown_title":
|
||||
title = self.extract_title(message_content)
|
||||
elif self.valves.TITLE_SOURCE == "ai_generated":
|
||||
title = await self.generate_title_using_ai(
|
||||
body, message_content, user_id, __request__
|
||||
)
|
||||
|
||||
current_datetime = datetime.datetime.now()
|
||||
formatted_date = current_datetime.strftime("%Y%m%d")
|
||||
|
||||
if title:
|
||||
filename = f"{self.clean_filename(title)}.docx"
|
||||
else:
|
||||
filename = f"{user_name}_{formatted_date}.docx"
|
||||
|
||||
# 创建 Word 文档;若正文无一级标题,使用对话标题作为一级标题
|
||||
# 如果选择了 chat_title 且获取到了,则作为 top_heading
|
||||
# 如果选择了其他方式,title 就是文件名,也可以作为 top_heading
|
||||
|
||||
# 保持原有逻辑:top_heading 主要是为了在文档开头补充标题
|
||||
# 这里我们尽量使用 chat_title 作为 top_heading,如果它存在的话,因为它通常是对话的主题
|
||||
# 即使文件名是 AI 生成的,文档内的标题用 chat_title 也是合理的
|
||||
# 但如果用户选择了 markdown_title,可能不希望插入 chat_title
|
||||
|
||||
top_heading = ""
|
||||
if chat_title:
|
||||
top_heading = chat_title
|
||||
elif title:
|
||||
top_heading = title
|
||||
|
||||
has_h1 = bool(re.search(r"^#\s+.+$", message_content, re.MULTILINE))
|
||||
doc = self.markdown_to_docx(
|
||||
message_content, top_heading=top_heading, has_h1=has_h1
|
||||
)
|
||||
|
||||
# 保存到内存
|
||||
doc_buffer = io.BytesIO()
|
||||
doc.save(doc_buffer)
|
||||
doc_buffer.seek(0)
|
||||
file_content = doc_buffer.read()
|
||||
base64_blob = base64.b64encode(file_content).decode("utf-8")
|
||||
|
||||
# 触发文件下载
|
||||
if __event_call__:
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {
|
||||
"code": f"""
|
||||
try {{
|
||||
const base64Data = "{base64_blob}";
|
||||
const binaryData = atob(base64Data);
|
||||
const arrayBuffer = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {{
|
||||
arrayBuffer[i] = binaryData.charCodeAt(i);
|
||||
}}
|
||||
const blob = new Blob([arrayBuffer], {{ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }});
|
||||
const filename = "{filename}";
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}} catch (error) {{
|
||||
console.error('触发下载时出错:', error);
|
||||
}}
|
||||
"""
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "Word 文档已导出", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
await self._send_notification(
|
||||
__event_emitter__, "success", f"已成功导出为 {filename}"
|
||||
)
|
||||
|
||||
return {"message": "下载事件已触发"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error exporting to Word: {str(e)}")
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"导出失败: {str(e)}",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
await self._send_notification(
|
||||
__event_emitter__, "error", f"导出 Word 文档时出错: {str(e)}"
|
||||
)
|
||||
|
||||
async def generate_title_using_ai(
|
||||
self, body: dict, content: str, user_id: str, request: Any
|
||||
) -> str:
|
||||
if not request:
|
||||
return ""
|
||||
|
||||
try:
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
model = body.get("model")
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant. Generate a short, concise title (max 10 words) for the following text. Do not use quotes. Only output the title.",
|
||||
},
|
||||
{"role": "user", "content": content[:2000]}, # Limit content length
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(request, payload, user_obj)
|
||||
if response and "choices" in response:
|
||||
return response["choices"][0]["message"]["content"].strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating title: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
def extract_title(self, content: str) -> str:
|
||||
"""从 Markdown 内容提取一级/二级标题"""
|
||||
lines = content.split("\n")
|
||||
for line in lines:
|
||||
# 仅匹配 h1-h2 标题
|
||||
match = re.match(r"^#{1,2}\s+(.+)$", line.strip())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return ""
|
||||
|
||||
def extract_chat_title(self, body: dict) -> str:
|
||||
"""从请求体中提取会话标题"""
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
|
||||
candidates = []
|
||||
|
||||
for key in ("chat", "conversation"):
|
||||
if isinstance(body.get(key), dict):
|
||||
candidates.append(body.get(key, {}).get("title", ""))
|
||||
|
||||
for key in ("title", "chat_title"):
|
||||
value = body.get(key)
|
||||
if isinstance(value, str):
|
||||
candidates.append(value)
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate and isinstance(candidate, str):
|
||||
return candidate.strip()
|
||||
return ""
|
||||
|
||||
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") or body.get("id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
for key in ("chat", "conversation"):
|
||||
nested = body.get(key)
|
||||
if isinstance(nested, dict):
|
||||
nested_id = nested.get("id") or nested.get("chat_id")
|
||||
if isinstance(nested_id, str) and nested_id.strip():
|
||||
return nested_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 ""
|
||||
|
||||
async def fetch_chat_title(self, chat_id: str, user_id: str = "") -> str:
|
||||
"""根据 chat_id 从数据库获取标题"""
|
||||
if not chat_id:
|
||||
return ""
|
||||
|
||||
def _load_chat():
|
||||
if user_id:
|
||||
return Chats.get_chat_by_id_and_user_id(id=chat_id, user_id=user_id)
|
||||
return Chats.get_chat_by_id(chat_id)
|
||||
|
||||
try:
|
||||
chat = await asyncio.to_thread(_load_chat)
|
||||
except Exception as exc:
|
||||
logger.warning(f"加载聊天 {chat_id} 失败: {exc}")
|
||||
return ""
|
||||
|
||||
if not chat:
|
||||
return ""
|
||||
|
||||
data = getattr(chat, "chat", {}) or {}
|
||||
title = data.get("title") or getattr(chat, "title", "")
|
||||
return title.strip() if isinstance(title, str) else ""
|
||||
|
||||
def clean_filename(self, name: str) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:50]
|
||||
|
||||
def markdown_to_docx(
|
||||
self, markdown_text: str, top_heading: str = "", has_h1: bool = False
|
||||
) -> Document:
|
||||
"""
|
||||
将 Markdown 文本转换为 Word 文档
|
||||
支持:标题、段落、粗体、斜体、代码块、列表、表格、链接
|
||||
"""
|
||||
doc = Document()
|
||||
|
||||
# 设置默认中文字体
|
||||
self.set_document_default_font(doc)
|
||||
|
||||
# 若正文无一级标题且有对话标题,则作为一级标题写入
|
||||
if top_heading and not has_h1:
|
||||
self.add_heading(doc, top_heading, 1)
|
||||
|
||||
lines = markdown_text.split("\n")
|
||||
i = 0
|
||||
in_code_block = False
|
||||
code_block_content = []
|
||||
code_block_lang = ""
|
||||
in_list = False
|
||||
list_items = []
|
||||
list_type = None # 'ordered' or 'unordered'
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# 处理代码块
|
||||
if line.strip().startswith("```"):
|
||||
if not in_code_block:
|
||||
# 先处理之前积累的列表
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
|
||||
in_code_block = True
|
||||
code_block_lang = line.strip()[3:].strip()
|
||||
code_block_content = []
|
||||
else:
|
||||
# 代码块结束
|
||||
in_code_block = False
|
||||
self.add_code_block(
|
||||
doc, "\n".join(code_block_content), code_block_lang
|
||||
)
|
||||
code_block_content = []
|
||||
code_block_lang = ""
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
code_block_content.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 处理表格
|
||||
if line.strip().startswith("|") and line.strip().endswith("|"):
|
||||
# 先处理之前积累的列表
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
|
||||
table_lines = []
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
table_lines.append(lines[i])
|
||||
i += 1
|
||||
self.add_table(doc, table_lines)
|
||||
continue
|
||||
|
||||
# 处理标题
|
||||
header_match = re.match(r"^(#{1,6})\s+(.+)$", line.strip())
|
||||
if header_match:
|
||||
# 先处理之前积累的列表
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
|
||||
level = len(header_match.group(1))
|
||||
text = header_match.group(2)
|
||||
self.add_heading(doc, text, level)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 处理无序列表
|
||||
unordered_match = re.match(r"^(\s*)[-*+]\s+(.+)$", line)
|
||||
if unordered_match:
|
||||
if not in_list or list_type != "unordered":
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = True
|
||||
list_type = "unordered"
|
||||
indent = len(unordered_match.group(1)) // 2
|
||||
list_items.append((indent, unordered_match.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 处理有序列表
|
||||
ordered_match = re.match(r"^(\s*)\d+[.)]\s+(.+)$", line)
|
||||
if ordered_match:
|
||||
if not in_list or list_type != "ordered":
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = True
|
||||
list_type = "ordered"
|
||||
indent = len(ordered_match.group(1)) // 2
|
||||
list_items.append((indent, ordered_match.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 处理引用块
|
||||
if line.strip().startswith(">"):
|
||||
# 先处理之前积累的列表
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
|
||||
# 收集连续的引用行
|
||||
blockquote_lines = []
|
||||
while i < len(lines) and lines[i].strip().startswith(">"):
|
||||
# 移除开头的 > 和可能的空格
|
||||
quote_line = re.sub(r"^>\s?", "", lines[i])
|
||||
blockquote_lines.append(quote_line)
|
||||
i += 1
|
||||
self.add_blockquote(doc, "\n".join(blockquote_lines))
|
||||
continue
|
||||
|
||||
# 处理水平分割线
|
||||
if re.match(r"^[-*_]{3,}$", line.strip()):
|
||||
# 先处理之前积累的列表
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
|
||||
self.add_horizontal_rule(doc)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 处理空行
|
||||
if not line.strip():
|
||||
# 列表结束
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 处理普通段落
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
list_items = []
|
||||
in_list = False
|
||||
|
||||
self.add_paragraph(doc, line)
|
||||
i += 1
|
||||
|
||||
# 处理剩余的列表
|
||||
if in_list and list_items:
|
||||
self.add_list_to_doc(doc, list_items, list_type)
|
||||
|
||||
return doc
|
||||
|
||||
def set_document_default_font(self, doc: Document):
|
||||
"""设置文档默认字体,确保中英文都正常显示"""
|
||||
# 设置正文样式
|
||||
style = doc.styles["Normal"]
|
||||
font = style.font
|
||||
font.name = "Times New Roman" # 英文字体
|
||||
font.size = Pt(11)
|
||||
|
||||
# 设置中文字体
|
||||
style._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
# 设置段落格式
|
||||
paragraph_format = style.paragraph_format
|
||||
paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
paragraph_format.space_after = Pt(6)
|
||||
|
||||
def add_heading(self, doc: Document, text: str, level: int):
|
||||
"""添加标题"""
|
||||
# Word 标题级别从 0 开始,Markdown 从 1 开始
|
||||
heading_level = min(level, 9) # Word 最多支持 Heading 9
|
||||
heading = doc.add_heading(level=heading_level)
|
||||
|
||||
# 解析并添加格式化文本
|
||||
self.add_formatted_text(heading, text)
|
||||
|
||||
# 设置中文字体
|
||||
for run in heading.runs:
|
||||
run.font.name = "Times New Roman"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "黑体")
|
||||
run.font.color.rgb = RGBColor(0, 0, 0)
|
||||
|
||||
def add_paragraph(self, doc: Document, text: str):
|
||||
"""添加段落,支持内联格式"""
|
||||
paragraph = doc.add_paragraph()
|
||||
self.add_formatted_text(paragraph, text)
|
||||
|
||||
# 设置中文字体
|
||||
for run in paragraph.runs:
|
||||
run.font.name = "Times New Roman"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
def add_formatted_text(self, paragraph, text: str):
|
||||
"""
|
||||
解析 Markdown 内联格式并添加到段落
|
||||
支持:粗体、斜体、行内代码、链接、删除线
|
||||
"""
|
||||
# 定义格式化模式
|
||||
patterns = [
|
||||
# 粗斜体 ***text*** 或 ___text___
|
||||
(r"\*\*\*(.+?)\*\*\*|___(.+?)___", {"bold": True, "italic": True}),
|
||||
# 粗体 **text** 或 __text__
|
||||
(r"\*\*(.+?)\*\*|__(.+?)__", {"bold": True}),
|
||||
# 斜体 *text* 或 _text_
|
||||
(
|
||||
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)",
|
||||
{"italic": True},
|
||||
),
|
||||
# 行内代码 `code`
|
||||
(r"`([^`]+)`", {"code": True}),
|
||||
# 链接 [text](url)
|
||||
(r"\[([^\]]+)\]\(([^)]+)\)", {"link": True}),
|
||||
# 删除线 ~~text~~
|
||||
(r"~~(.+?)~~", {"strike": True}),
|
||||
]
|
||||
|
||||
# 简化处理:逐段解析
|
||||
remaining = text
|
||||
last_end = 0
|
||||
|
||||
# 合并所有匹配项
|
||||
all_matches = []
|
||||
|
||||
for pattern, style in patterns:
|
||||
for match in re.finditer(pattern, text):
|
||||
# 获取匹配的文本内容
|
||||
groups = match.groups()
|
||||
matched_text = next((g for g in groups if g is not None), "")
|
||||
all_matches.append(
|
||||
{
|
||||
"start": match.start(),
|
||||
"end": match.end(),
|
||||
"text": matched_text,
|
||||
"style": style,
|
||||
"full_match": match.group(0),
|
||||
"url": (
|
||||
groups[1] if style.get("link") and len(groups) > 1 else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# 按位置排序
|
||||
all_matches.sort(key=lambda x: x["start"])
|
||||
|
||||
# 移除重叠的匹配
|
||||
filtered_matches = []
|
||||
last_end = 0
|
||||
for m in all_matches:
|
||||
if m["start"] >= last_end:
|
||||
filtered_matches.append(m)
|
||||
last_end = m["end"]
|
||||
|
||||
# 构建最终文本
|
||||
pos = 0
|
||||
for match in filtered_matches:
|
||||
# 添加匹配前的普通文本
|
||||
if match["start"] > pos:
|
||||
plain_text = text[pos : match["start"]]
|
||||
if plain_text:
|
||||
paragraph.add_run(plain_text)
|
||||
|
||||
# 添加格式化文本
|
||||
style = match["style"]
|
||||
run_text = match["text"]
|
||||
|
||||
if style.get("link"):
|
||||
# 链接处理
|
||||
run = paragraph.add_run(run_text)
|
||||
run.font.color.rgb = RGBColor(0, 0, 255)
|
||||
run.font.underline = True
|
||||
elif style.get("code"):
|
||||
# 行内代码
|
||||
run = paragraph.add_run(run_text)
|
||||
run.font.name = "Consolas"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "SimHei")
|
||||
run.font.size = Pt(10)
|
||||
# 添加背景色
|
||||
shading = OxmlElement("w:shd")
|
||||
shading.set(qn("w:fill"), "E8E8E8")
|
||||
run._element.rPr.append(shading)
|
||||
else:
|
||||
run = paragraph.add_run(run_text)
|
||||
if style.get("bold"):
|
||||
run.bold = True
|
||||
if style.get("italic"):
|
||||
run.italic = True
|
||||
if style.get("strike"):
|
||||
run.font.strike = True
|
||||
|
||||
pos = match["end"]
|
||||
|
||||
# 添加剩余的普通文本
|
||||
if pos < len(text):
|
||||
paragraph.add_run(text[pos:])
|
||||
|
||||
def add_code_block(self, doc: Document, code: str, language: str = ""):
|
||||
"""添加代码块,支持语法高亮"""
|
||||
# 语法高亮颜色映射 (基于常见的 IDE 配色)
|
||||
TOKEN_COLORS = {
|
||||
Token.Keyword: RGBColor(0, 92, 197), # macOS 风格蓝 - 关键字
|
||||
Token.Keyword.Constant: RGBColor(0, 92, 197),
|
||||
Token.Keyword.Declaration: RGBColor(0, 92, 197),
|
||||
Token.Keyword.Namespace: RGBColor(0, 92, 197),
|
||||
Token.Keyword.Type: RGBColor(0, 92, 197),
|
||||
Token.Name.Function: RGBColor(0, 0, 0), # 函数名保持黑色
|
||||
Token.Name.Class: RGBColor(38, 82, 120), # 深青蓝 - 类名
|
||||
Token.Name.Decorator: RGBColor(170, 51, 0), # 暖橙 - 装饰器
|
||||
Token.Name.Builtin: RGBColor(0, 110, 71), # 墨绿 - 内置
|
||||
Token.String: RGBColor(196, 26, 22), # 红色 - 字符串
|
||||
Token.String.Doc: RGBColor(109, 120, 133), # 灰 - 文档字符串
|
||||
Token.Comment: RGBColor(109, 120, 133), # 灰 - 注释
|
||||
Token.Comment.Single: RGBColor(109, 120, 133),
|
||||
Token.Comment.Multiline: RGBColor(109, 120, 133),
|
||||
Token.Number: RGBColor(28, 0, 207), # 靛蓝 - 数字
|
||||
Token.Number.Integer: RGBColor(28, 0, 207),
|
||||
Token.Number.Float: RGBColor(28, 0, 207),
|
||||
Token.Operator: RGBColor(90, 99, 120), # 灰蓝 - 运算符
|
||||
Token.Punctuation: RGBColor(0, 0, 0), # 黑色 - 标点
|
||||
}
|
||||
|
||||
def get_token_color(token_type):
|
||||
"""递归查找 token 颜色"""
|
||||
while token_type:
|
||||
if token_type in TOKEN_COLORS:
|
||||
return TOKEN_COLORS[token_type]
|
||||
token_type = token_type.parent
|
||||
return None
|
||||
|
||||
# 添加语言标签(如果有)
|
||||
if language:
|
||||
lang_para = doc.add_paragraph()
|
||||
lang_para.paragraph_format.space_before = Pt(6)
|
||||
lang_para.paragraph_format.space_after = Pt(0)
|
||||
lang_para.paragraph_format.left_indent = Cm(0.5)
|
||||
lang_run = lang_para.add_run(language.upper())
|
||||
lang_run.font.name = "Consolas"
|
||||
lang_run.font.size = Pt(8)
|
||||
lang_run.font.color.rgb = RGBColor(100, 100, 100)
|
||||
lang_run.font.bold = True
|
||||
|
||||
# 添加代码块段落
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.left_indent = Cm(0.5)
|
||||
paragraph.paragraph_format.space_before = Pt(3) if language else Pt(6)
|
||||
paragraph.paragraph_format.space_after = Pt(6)
|
||||
|
||||
# 添加浅灰色背景
|
||||
shading = OxmlElement("w:shd")
|
||||
shading.set(qn("w:fill"), "F7F7F7")
|
||||
paragraph._element.pPr.append(shading)
|
||||
|
||||
# 尝试使用 Pygments 进行语法高亮
|
||||
if PYGMENTS_AVAILABLE and language:
|
||||
try:
|
||||
lexer = get_lexer_by_name(language, stripall=False)
|
||||
except Exception:
|
||||
lexer = TextLexer()
|
||||
|
||||
tokens = list(lex(code, lexer))
|
||||
|
||||
for token_type, token_value in tokens:
|
||||
if not token_value:
|
||||
continue
|
||||
run = paragraph.add_run(token_value)
|
||||
run.font.name = "Consolas"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "SimHei")
|
||||
run.font.size = Pt(10)
|
||||
|
||||
# 应用颜色
|
||||
color = get_token_color(token_type)
|
||||
if color:
|
||||
run.font.color.rgb = color
|
||||
|
||||
# 关键字加粗
|
||||
if token_type in Token.Keyword:
|
||||
run.font.bold = True
|
||||
else:
|
||||
# 无语法高亮,纯文本显示
|
||||
run = paragraph.add_run(code)
|
||||
run.font.name = "Consolas"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "SimHei")
|
||||
run.font.size = Pt(10)
|
||||
|
||||
def add_table(self, doc: Document, table_lines: List[str]):
|
||||
"""添加表格,支持表头底色与隔行底色"""
|
||||
if len(table_lines) < 2:
|
||||
return
|
||||
|
||||
def _set_cell_shading(cell, fill: str):
|
||||
tc_pr = cell._element.get_or_add_tcPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:fill"), fill)
|
||||
tc_pr.append(shd)
|
||||
|
||||
header_fill = "F2F2F2"
|
||||
zebra_fill = "FBFBFB"
|
||||
|
||||
# 解析表格数据
|
||||
rows = []
|
||||
for line in table_lines:
|
||||
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
# 跳过分隔行
|
||||
if all(re.fullmatch(r"[-:]+", cell) for cell in cells):
|
||||
continue
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
# 确定列数
|
||||
num_cols = max(len(row) for row in rows)
|
||||
|
||||
# 创建表格
|
||||
table = doc.add_table(rows=len(rows), cols=num_cols)
|
||||
table.style = "Table Grid"
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
|
||||
# 填充表格
|
||||
for row_idx, row_data in enumerate(rows):
|
||||
row = table.rows[row_idx]
|
||||
for col_idx, cell_text in enumerate(row_data):
|
||||
if col_idx < num_cols:
|
||||
cell = row.cells[col_idx]
|
||||
# 清除默认段落
|
||||
cell.paragraphs[0].clear()
|
||||
para = cell.paragraphs[0]
|
||||
para.paragraph_format.space_after = Pt(3)
|
||||
para.paragraph_format.space_before = Pt(1)
|
||||
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
|
||||
self.add_formatted_text(para, cell_text)
|
||||
|
||||
# 设置单元格字体
|
||||
for run in para.runs:
|
||||
run.font.name = "Times New Roman"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
run.font.size = Pt(10)
|
||||
|
||||
# 表头加粗并填充底色
|
||||
if row_idx == 0:
|
||||
for run in para.runs:
|
||||
run.bold = True
|
||||
_set_cell_shading(cell, header_fill)
|
||||
# 隔行底色
|
||||
elif row_idx % 2 == 1:
|
||||
_set_cell_shading(cell, zebra_fill)
|
||||
|
||||
# 统一列对齐为左对齐,避免居中导致阅读困难
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
|
||||
def add_list_to_doc(
|
||||
self, doc: Document, items: List[Tuple[int, str]], list_type: str
|
||||
):
|
||||
"""添加列表"""
|
||||
for indent, text in items:
|
||||
paragraph = doc.add_paragraph()
|
||||
|
||||
if list_type == "unordered":
|
||||
# 无序列表使用项目符号
|
||||
paragraph.style = "List Bullet"
|
||||
else:
|
||||
# 有序列表使用编号
|
||||
paragraph.style = "List Number"
|
||||
|
||||
# 设置缩进
|
||||
paragraph.paragraph_format.left_indent = Cm(0.5 * (indent + 1))
|
||||
|
||||
# 添加格式化文本
|
||||
self.add_formatted_text(paragraph, text)
|
||||
|
||||
# 设置字体
|
||||
for run in paragraph.runs:
|
||||
run.font.name = "Times New Roman"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
def add_horizontal_rule(self, doc: Document):
|
||||
"""添加水平分割线"""
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.space_before = Pt(12)
|
||||
paragraph.paragraph_format.space_after = Pt(12)
|
||||
|
||||
# 添加底部边框作为分割线
|
||||
pPr = paragraph._element.get_or_add_pPr()
|
||||
pBdr = OxmlElement("w:pBdr")
|
||||
bottom = OxmlElement("w:bottom")
|
||||
bottom.set(qn("w:val"), "single")
|
||||
bottom.set(qn("w:sz"), "6")
|
||||
bottom.set(qn("w:space"), "1")
|
||||
bottom.set(qn("w:color"), "auto")
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
def add_blockquote(self, doc: Document, text: str):
|
||||
"""添加引用块,带有左侧边框和灰色背景"""
|
||||
for line in text.split("\n"):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.left_indent = Cm(1.0)
|
||||
paragraph.paragraph_format.space_before = Pt(3)
|
||||
paragraph.paragraph_format.space_after = Pt(3)
|
||||
|
||||
# 添加左侧边框
|
||||
pPr = paragraph._element.get_or_add_pPr()
|
||||
pBdr = OxmlElement("w:pBdr")
|
||||
left = OxmlElement("w:left")
|
||||
left.set(qn("w:val"), "single")
|
||||
left.set(qn("w:sz"), "24") # 边框粗细
|
||||
left.set(qn("w:space"), "4") # 边框与文字间距
|
||||
left.set(qn("w:color"), "CCCCCC") # 灰色边框
|
||||
pBdr.append(left)
|
||||
pPr.append(pBdr)
|
||||
|
||||
# 添加浅灰色背景
|
||||
shading = OxmlElement("w:shd")
|
||||
shading.set(qn("w:fill"), "F9F9F9")
|
||||
pPr.append(shading)
|
||||
|
||||
# 添加格式化文本
|
||||
self.add_formatted_text(paragraph, line)
|
||||
|
||||
# 设置字体为斜体灰色
|
||||
for run in paragraph.runs:
|
||||
run.font.name = "Times New Roman"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "楷体")
|
||||
run.font.color.rgb = RGBColor(85, 85, 85) # 深灰色文字
|
||||
run.italic = True
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
This plugin allows you to export your chat history to an Excel (.xlsx) file directly from the chat interface.
|
||||
|
||||
### What's New in v0.3.5
|
||||
## What's New in v0.3.6
|
||||
|
||||
- **OpenWebUI-Style Theme**: Modern dark header (#1f2937) with light gray zebra striping for better readability.
|
||||
- **Zebra Striping**: Alternating row colors (#ffffff / #f3f4f6) for improved visual scanning.
|
||||
- **Smart Data Type Conversion**: Automatically converts columns to numeric or datetime types with fallback to string.
|
||||
- **Full Cell Bold/Italic**: Supports full cell Markdown bold (`**text**`) and italic (`*text*`) formatting in Excel.
|
||||
- **Partial Markdown Cleanup**: Automatically removes partial Markdown formatting symbols (e.g., `Some **bold** text` → `Some bold text`) for cleaner Excel output.
|
||||
- **Export Scope**: Added `EXPORT_SCOPE` valve to choose between exporting tables from the "Last Message" (default) or "All Messages".
|
||||
- **Smart Sheet Naming**: Automatically names sheets based on Markdown headers, AI titles (if enabled), or message index (e.g., `Msg1-Tab1`).
|
||||
- **Multiple Tables Support**: Improved handling of multiple tables within single or multiple messages.
|
||||
|
||||
## What's New in v0.3.4
|
||||
|
||||
- **Smart Filename Generation**: Now supports generating filenames based on Chat Title, AI Summary, or Markdown Headers.
|
||||
- **Smart Filename Generation**: Supports generating filenames based on Chat Title, AI Summary, or Markdown Headers.
|
||||
- **Configuration Options**: Added `TITLE_SOURCE` setting to control filename generation strategy.
|
||||
- **AI Title Generation**: Added `MODEL_ID` setting to specify the model for AI title generation, with progress notifications.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
|
||||
此插件允许你直接从聊天界面将对话历史导出为 Excel (.xlsx) 文件。
|
||||
|
||||
### v0.3.5 更新内容
|
||||
- **导出范围**: 新增 `EXPORT_SCOPE` 配置项,可选择导出“最后一条消息”(默认)或“所有消息”中的表格。
|
||||
## v0.3.6 更新内容
|
||||
|
||||
- **OpenWebUI 风格主题**:现代深灰表头 (#1f2937),搭配浅灰斑马纹,提升可读性。
|
||||
- **斑马纹效果**:隔行变色(#ffffff / #f3f4f6),方便视觉扫描。
|
||||
- **智能数据类型转换**:自动将列转换为数字或日期类型,无法转换时保持字符串。
|
||||
- **全单元格粗体/斜体**:支持 Excel 中的全单元格 Markdown 粗体 (`**text**`) 和斜体 (`*text*`) 格式。
|
||||
- **部分 Markdown 清理**:自动移除部分 Markdown 格式符号(如 `部分**加粗**文本` → `部分加粗文本`),使 Excel 输出更整洁。
|
||||
- **导出范围**: 新增 `EXPORT_SCOPE` 配置项,可选择导出"最后一条消息"(默认)或"所有消息"中的表格。
|
||||
- **智能 Sheet 命名**: 根据 Markdown 标题、AI 标题(如启用)或消息索引(如 `消息1-表1`)自动命名 Sheet。
|
||||
- **多表格支持**: 优化了对单条或多条消息中包含多个表格的处理。
|
||||
|
||||
## v0.3.4 更新内容
|
||||
|
||||
- **智能文件名生成**:支持根据对话标题、AI 总结或 Markdown 标题生成文件名。
|
||||
- **配置选项**:新增 `TITLE_SOURCE` 设置,用于控制文件名生成策略。
|
||||
- **AI 标题生成**:新增 `MODEL_ID` 设置用于指定 AI 标题生成模型,并支持生成进度通知。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **一键导出**:在聊天界面添加“导出为 Excel”按钮。
|
||||
- **一键导出**:在聊天界面添加"导出为 Excel"按钮。
|
||||
- **自动表头提取**:智能识别聊天内容中的表格标题。
|
||||
- **多表支持**:支持处理单次对话中的多个表格。
|
||||
|
||||
@@ -28,7 +32,7 @@
|
||||
## 使用方法
|
||||
|
||||
1. 安装插件。
|
||||
2. 在任意对话中,点击“导出为 Excel”按钮。
|
||||
2. 在任意对话中,点击"导出为 Excel"按钮。
|
||||
3. 文件将自动下载到你的设备。
|
||||
|
||||
## 作者
|
||||
|
||||
@@ -3,9 +3,9 @@ title: Export to Excel
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.3.5
|
||||
version: 0.3.7
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAySDZhMiAyIDAgMCAwLTIgMnYxNmEyIDIgMCAwIDAgMiAyaDEyYTIgMiAwIDAgMCAyLTJWN1oiLz48cGF0aCBkPSJNMTQgMnY0YTIgMiAwIDAgMCAyIDJoNCIvPjxwYXRoIGQ9Ik04IDEzaDIiLz48cGF0aCBkPSJNMTQgMTNoMiIvPjxwYXRoIGQ9Ik04IDE3aDIiLz48cGF0aCBkPSJNMTQgMTdoMiIvPjwvc3ZnPg==
|
||||
description: Exports the current chat history to an Excel (.xlsx) file, with automatic header extraction.
|
||||
description: Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -20,20 +20,25 @@ from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
TITLE_SOURCE: str = Field(
|
||||
TITLE_SOURCE: Literal["chat_title", "ai_generated", "markdown_title"] = Field(
|
||||
default="chat_title",
|
||||
description="Title Source: 'chat_title' (Chat Title), 'ai_generated' (AI Generated), 'markdown_title' (Markdown Title)",
|
||||
)
|
||||
EXPORT_SCOPE: str = Field(
|
||||
EXPORT_SCOPE: Literal["last_message", "all_messages"] = Field(
|
||||
default="last_message",
|
||||
description="Export Scope: 'last_message' (Last Message Only), 'all_messages' (All Messages)",
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="Model ID for AI title generation. Leave empty to use the current chat model.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
@@ -181,6 +186,16 @@ class Action:
|
||||
seen_names[name] = True
|
||||
final_sheet_names.append(name)
|
||||
|
||||
# Notify user about the number of tables found
|
||||
table_count = len(all_tables)
|
||||
if self.valves.EXPORT_SCOPE == "all_messages":
|
||||
await self._send_notification(
|
||||
__event_emitter__,
|
||||
"info",
|
||||
f"Found {table_count} table(s) in all messages.",
|
||||
)
|
||||
# Wait a moment for user to see the notification before download dialog
|
||||
await asyncio.sleep(1.5)
|
||||
# Generate Workbook Title (Filename)
|
||||
# Use the title of the chat, or the first header of the first message with tables
|
||||
title = ""
|
||||
@@ -194,6 +209,24 @@ class Action:
|
||||
or not self.valves.TITLE_SOURCE
|
||||
):
|
||||
title = chat_title
|
||||
elif self.valves.TITLE_SOURCE == "ai_generated":
|
||||
# Use AI to generate a title based on message content
|
||||
if target_messages and __request__:
|
||||
# Get content from the first message with tables
|
||||
content_for_title = ""
|
||||
for msg in target_messages:
|
||||
msg_content = msg.get("content", "")
|
||||
if msg_content:
|
||||
content_for_title = msg_content
|
||||
break
|
||||
if content_for_title:
|
||||
title = await self.generate_title_using_ai(
|
||||
body,
|
||||
content_for_title,
|
||||
user_id,
|
||||
__request__,
|
||||
__event_emitter__,
|
||||
)
|
||||
elif self.valves.TITLE_SOURCE == "markdown_title":
|
||||
# Try to find first header in the first message that has content
|
||||
for msg in target_messages:
|
||||
@@ -316,32 +349,93 @@ class Action:
|
||||
)
|
||||
|
||||
async def generate_title_using_ai(
|
||||
self, body: dict, content: str, user_id: str, request: Any
|
||||
self,
|
||||
body: dict,
|
||||
content: str,
|
||||
user_id: str,
|
||||
request: Any,
|
||||
event_emitter: Callable = None,
|
||||
) -> str:
|
||||
if not request:
|
||||
return ""
|
||||
|
||||
try:
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
model = body.get("model")
|
||||
# Use configured MODEL_ID or fallback to current chat model
|
||||
model = (
|
||||
self.valves.MODEL_ID.strip()
|
||||
if self.valves.MODEL_ID
|
||||
else body.get("model")
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant. Generate a short, concise title (max 10 words) for the following text. Do not use quotes. Only output the title.",
|
||||
"content": "You are a helpful assistant. Generate a short, concise filename (max 10 words) for an Excel export based on the following content. Do not use quotes or file extensions. Avoid special characters that are invalid in filenames. Only output the filename.",
|
||||
},
|
||||
{"role": "user", "content": content[:2000]}, # Limit content length
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(request, payload, user_obj)
|
||||
# Define the generation task
|
||||
async def generate_task():
|
||||
return await generate_chat_completion(request, payload, user_obj)
|
||||
|
||||
# Define the notification task
|
||||
async def notification_task():
|
||||
# Send initial notification immediately
|
||||
if event_emitter:
|
||||
await self._send_notification(
|
||||
event_emitter,
|
||||
"info",
|
||||
"AI is generating a filename for your Excel file...",
|
||||
)
|
||||
|
||||
# Subsequent notifications every 5 seconds
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
if event_emitter:
|
||||
await self._send_notification(
|
||||
event_emitter,
|
||||
"info",
|
||||
"Still generating filename, please be patient...",
|
||||
)
|
||||
|
||||
# Run tasks concurrently
|
||||
gen_future = asyncio.ensure_future(generate_task())
|
||||
notify_future = asyncio.ensure_future(notification_task())
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[gen_future, notify_future], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
# Cancel notification task if generation is done
|
||||
if not notify_future.done():
|
||||
notify_future.cancel()
|
||||
|
||||
# Get result
|
||||
if gen_future in done:
|
||||
response = gen_future.result()
|
||||
if response and "choices" in response:
|
||||
return response["choices"][0]["message"]["content"].strip()
|
||||
else:
|
||||
# Should not happen if return_when=FIRST_COMPLETED and we cancel notify
|
||||
await gen_future
|
||||
response = gen_future.result()
|
||||
if response and "choices" in response:
|
||||
return response["choices"][0]["message"]["content"].strip()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating title: {e}")
|
||||
if event_emitter:
|
||||
await self._send_notification(
|
||||
event_emitter,
|
||||
"warning",
|
||||
f"AI title generation failed, using default title. Error: {str(e)}",
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -681,24 +775,51 @@ class Action:
|
||||
with pd.ExcelWriter(file_path, engine="xlsxwriter") as writer:
|
||||
workbook = writer.book
|
||||
|
||||
# OpenWebUI-style theme colors
|
||||
HEADER_BG = "#1f2937" # Dark gray (matches OpenWebUI sidebar)
|
||||
HEADER_FG = "#ffffff" # White text
|
||||
ROW_ODD_BG = "#ffffff" # White for odd rows
|
||||
ROW_EVEN_BG = "#f3f4f6" # Light gray for even rows (zebra striping)
|
||||
BORDER_COLOR = "#e5e7eb" # Light border
|
||||
|
||||
# Define header style - Center aligned
|
||||
header_format = workbook.add_format(
|
||||
{
|
||||
"bold": True,
|
||||
"font_size": 12,
|
||||
"font_color": "white",
|
||||
"bg_color": "#00abbd",
|
||||
"font_size": 11,
|
||||
"font_name": "Arial",
|
||||
"font_color": HEADER_FG,
|
||||
"bg_color": HEADER_BG,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Text cell style - Left aligned
|
||||
# Text cell style - Left aligned (odd rows)
|
||||
text_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Text cell style - Left aligned (even rows - zebra)
|
||||
text_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
@@ -707,14 +828,51 @@ class Action:
|
||||
|
||||
# Number cell style - Right aligned
|
||||
number_format = workbook.add_format(
|
||||
{"border": 1, "align": "right", "valign": "vcenter"}
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
number_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# Integer format - Right aligned
|
||||
integer_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
integer_format_alt = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
@@ -724,7 +882,24 @@ class Action:
|
||||
decimal_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
decimal_format_alt = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
@@ -733,7 +908,24 @@ class Action:
|
||||
# Date format - Center aligned
|
||||
date_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
date_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
@@ -743,7 +935,23 @@ class Action:
|
||||
# Sequence format - Center aligned
|
||||
sequence_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
sequence_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
@@ -752,7 +960,25 @@ class Action:
|
||||
# Bold cell style (for full cell bolding)
|
||||
text_bold_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
"bold": True,
|
||||
}
|
||||
)
|
||||
|
||||
text_bold_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
@@ -763,7 +989,11 @@ class Action:
|
||||
# Italic cell style (for full cell italics)
|
||||
text_italic_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
@@ -771,6 +1001,48 @@ class Action:
|
||||
}
|
||||
)
|
||||
|
||||
text_italic_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
"italic": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Code cell style (for inline code with highlight background)
|
||||
CODE_BG = "#f0f0f0" # Light gray background for code
|
||||
text_code_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Consolas",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": CODE_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
text_code_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Consolas",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": CODE_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
try:
|
||||
table_data = table["data"]
|
||||
@@ -812,12 +1084,18 @@ class Action:
|
||||
|
||||
print(f"DataFrame created with columns: {list(df.columns)}")
|
||||
|
||||
# Fix pandas FutureWarning
|
||||
# Smart data type conversion using pandas infer_objects
|
||||
for col in df.columns:
|
||||
# Try numeric conversion first
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Try datetime conversion
|
||||
try:
|
||||
df[col] = pd.to_datetime(df[col], errors="raise")
|
||||
except (ValueError, TypeError):
|
||||
# Keep as string, use infer_objects for optimization
|
||||
df[col] = df[col].infer_objects()
|
||||
|
||||
# Write data first (without header)
|
||||
df.to_excel(
|
||||
@@ -829,21 +1107,25 @@ class Action:
|
||||
)
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# Apply enhanced formatting
|
||||
# Apply enhanced formatting with zebra striping
|
||||
formats = {
|
||||
"header": header_format,
|
||||
"text": [text_format, text_format_alt],
|
||||
"number": [number_format, number_format_alt],
|
||||
"integer": [integer_format, integer_format_alt],
|
||||
"decimal": [decimal_format, decimal_format_alt],
|
||||
"date": [date_format, date_format_alt],
|
||||
"sequence": [sequence_format, sequence_format_alt],
|
||||
"bold": [text_bold_format, text_bold_format_alt],
|
||||
"italic": [text_italic_format, text_italic_format_alt],
|
||||
"code": [text_code_format, text_code_format_alt],
|
||||
}
|
||||
self.apply_enhanced_formatting(
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
text_bold_format,
|
||||
text_italic_format,
|
||||
formats,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -860,26 +1142,22 @@ class Action:
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
text_bold_format=None,
|
||||
text_italic_format=None,
|
||||
formats,
|
||||
):
|
||||
"""
|
||||
Apply enhanced formatting
|
||||
- Header: Center aligned
|
||||
Apply enhanced formatting with zebra striping
|
||||
- Header: Center aligned (dark background)
|
||||
- Number: Right aligned
|
||||
- Text: Left aligned
|
||||
- Date: Center aligned
|
||||
- Sequence: Center aligned
|
||||
- Zebra striping: alternating row colors
|
||||
- Supports full cell Markdown bold (**text**) and italic (*text*)
|
||||
"""
|
||||
try:
|
||||
# Extract format from formats dict
|
||||
header_format = formats["header"]
|
||||
|
||||
# 1. Write headers (Center aligned)
|
||||
print(f"Writing headers with enhanced alignment: {headers}")
|
||||
for col_idx, header in enumerate(headers):
|
||||
@@ -903,62 +1181,97 @@ class Action:
|
||||
else:
|
||||
column_types[col_idx] = "text"
|
||||
|
||||
# 3. Write and format data
|
||||
# 3. Write and format data with zebra striping
|
||||
for row_idx, row in df.iterrows():
|
||||
# Determine if odd or even row (0-indexed, so row 0 is odd visually as row 1)
|
||||
is_alt_row = (
|
||||
row_idx % 2 == 1
|
||||
) # Even index = odd visual row, use alt format
|
||||
|
||||
for col_idx, value in enumerate(row):
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
|
||||
# Select format based on content type
|
||||
# Select format based on content type and zebra striping
|
||||
fmt_idx = 1 if is_alt_row else 0
|
||||
|
||||
if content_type == "number":
|
||||
# Number - Right aligned
|
||||
if pd.api.types.is_numeric_dtype(df.iloc[:, col_idx]):
|
||||
if pd.api.types.is_integer_dtype(df.iloc[:, col_idx]):
|
||||
current_format = integer_format
|
||||
current_format = formats["integer"][fmt_idx]
|
||||
else:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
if numeric_value.is_integer():
|
||||
current_format = integer_format
|
||||
current_format = formats["integer"][fmt_idx]
|
||||
value = int(numeric_value)
|
||||
else:
|
||||
current_format = decimal_format
|
||||
current_format = formats["decimal"][fmt_idx]
|
||||
except (ValueError, TypeError):
|
||||
current_format = decimal_format
|
||||
current_format = formats["decimal"][fmt_idx]
|
||||
else:
|
||||
current_format = number_format
|
||||
current_format = formats["number"][fmt_idx]
|
||||
|
||||
elif content_type == "date":
|
||||
# Date - Center aligned
|
||||
current_format = date_format
|
||||
current_format = formats["date"][fmt_idx]
|
||||
|
||||
elif content_type == "sequence":
|
||||
# Sequence - Center aligned
|
||||
current_format = sequence_format
|
||||
current_format = formats["sequence"][fmt_idx]
|
||||
|
||||
else:
|
||||
# Text - Left aligned
|
||||
current_format = text_format
|
||||
current_format = formats["text"][fmt_idx]
|
||||
|
||||
if content_type == "text" and isinstance(value, str):
|
||||
# Check for full cell bold (**text**)
|
||||
match_bold = re.fullmatch(r"\*\*(.+)\*\*", value.strip())
|
||||
# Check for full cell italic (*text*)
|
||||
match_italic = re.fullmatch(r"\*(.+)\*", value.strip())
|
||||
# Check for full cell code (`text`)
|
||||
match_code = re.fullmatch(r"`(.+)`", value.strip())
|
||||
|
||||
if match_bold:
|
||||
# Extract content and apply bold format
|
||||
clean_value = match_bold.group(1)
|
||||
worksheet.write(
|
||||
row_idx + 1, col_idx, clean_value, text_bold_format
|
||||
row_idx + 1,
|
||||
col_idx,
|
||||
clean_value,
|
||||
formats["bold"][fmt_idx],
|
||||
)
|
||||
elif match_italic:
|
||||
# Extract content and apply italic format
|
||||
clean_value = match_italic.group(1)
|
||||
worksheet.write(
|
||||
row_idx + 1, col_idx, clean_value, text_italic_format
|
||||
row_idx + 1,
|
||||
col_idx,
|
||||
clean_value,
|
||||
formats["italic"][fmt_idx],
|
||||
)
|
||||
elif match_code:
|
||||
# Extract content and apply code format (highlighted)
|
||||
clean_value = match_code.group(1)
|
||||
worksheet.write(
|
||||
row_idx + 1,
|
||||
col_idx,
|
||||
clean_value,
|
||||
formats["code"][fmt_idx],
|
||||
)
|
||||
else:
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
# Remove partial markdown formatting symbols (can't render partial formatting in Excel)
|
||||
# Remove bold markers **text** -> text
|
||||
clean_value = re.sub(r"\*\*(.+?)\*\*", r"\1", value)
|
||||
# Remove italic markers *text* -> text (but not inside **)
|
||||
clean_value = re.sub(
|
||||
r"(?<!\*)\*([^*]+)\*(?!\*)", r"\1", clean_value
|
||||
)
|
||||
# Remove code markers `text` -> text
|
||||
clean_value = re.sub(r"`(.+?)`", r"\1", clean_value)
|
||||
worksheet.write(
|
||||
row_idx + 1, col_idx, clean_value, current_format
|
||||
)
|
||||
else:
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ title: 导出为 Excel
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.3.5
|
||||
version: 0.3.7
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAySDZhMiAyIDAgMCAwLTIgMnYxNmEyIDIgMCAwIDAgMiAyaDEyYTIgMiAwIDAgMCAyLTJWN1oiLz48cGF0aCBkPSJNMTQgMnY0YTIgMiAwIDAgMCAyIDJoNCIvPjxwYXRoIGQ9Ik04IDEzaDIiLz48cGF0aCBkPSJNMTQgMTNoMiIvPjxwYXRoIGQ9Ik04IDE3aDIiLz48cGF0aCBkPSJNMTQgMTdoMiIvPjwvc3ZnPg==
|
||||
description: 将当前对话历史导出为 Excel (.xlsx) 文件,支持自动提取表头。
|
||||
description: 从聊天消息中提取表格并导出为 Excel (.xlsx) 文件,支持智能格式化。
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -20,20 +20,25 @@ from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Action:
|
||||
class Valves(BaseModel):
|
||||
TITLE_SOURCE: str = Field(
|
||||
TITLE_SOURCE: Literal["chat_title", "ai_generated", "markdown_title"] = Field(
|
||||
default="chat_title",
|
||||
description="标题来源: 'chat_title' (对话标题), 'ai_generated' (AI生成), 'markdown_title' (Markdown标题)",
|
||||
)
|
||||
EXPORT_SCOPE: str = Field(
|
||||
EXPORT_SCOPE: Literal["last_message", "all_messages"] = Field(
|
||||
default="last_message",
|
||||
description="导出范围: 'last_message' (仅最后一条消息), 'all_messages' (所有消息)",
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="AI 标题生成模型 ID。留空则使用当前对话模型。",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
@@ -172,6 +177,17 @@ class Action:
|
||||
seen_names[name] = True
|
||||
final_sheet_names.append(name)
|
||||
|
||||
# 通知用户提取到的表格数量
|
||||
table_count = len(all_tables)
|
||||
if self.valves.EXPORT_SCOPE == "all_messages":
|
||||
await self._send_notification(
|
||||
__event_emitter__,
|
||||
"info",
|
||||
f"从所有消息中提取到 {table_count} 个表格。",
|
||||
)
|
||||
# 等待片刻让用户看到通知,再触发下载
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# Generate Workbook Title (Filename)
|
||||
title = ""
|
||||
chat_id = self.extract_chat_id(body, None)
|
||||
@@ -184,6 +200,24 @@ class Action:
|
||||
or not self.valves.TITLE_SOURCE
|
||||
):
|
||||
title = chat_title
|
||||
elif self.valves.TITLE_SOURCE == "ai_generated":
|
||||
# 使用 AI 根据消息内容生成标题
|
||||
if target_messages and __request__:
|
||||
# 获取第一条有表格的消息内容
|
||||
content_for_title = ""
|
||||
for msg in target_messages:
|
||||
msg_content = msg.get("content", "")
|
||||
if msg_content:
|
||||
content_for_title = msg_content
|
||||
break
|
||||
if content_for_title:
|
||||
title = await self.generate_title_using_ai(
|
||||
body,
|
||||
content_for_title,
|
||||
user_id,
|
||||
__request__,
|
||||
__event_emitter__,
|
||||
)
|
||||
elif self.valves.TITLE_SOURCE == "markdown_title":
|
||||
for msg in target_messages:
|
||||
extracted = self.extract_title(msg.get("content", ""))
|
||||
@@ -304,32 +338,93 @@ class Action:
|
||||
)
|
||||
|
||||
async def generate_title_using_ai(
|
||||
self, body: dict, content: str, user_id: str, request: Any
|
||||
self,
|
||||
body: dict,
|
||||
content: str,
|
||||
user_id: str,
|
||||
request: Any,
|
||||
event_emitter: Callable = None,
|
||||
) -> str:
|
||||
if not request:
|
||||
return ""
|
||||
|
||||
try:
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
model = body.get("model")
|
||||
# 使用配置的 MODEL_ID 或回退到当前对话模型
|
||||
model = (
|
||||
self.valves.MODEL_ID.strip()
|
||||
if self.valves.MODEL_ID
|
||||
else body.get("model")
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个乐于助人的助手。请为以下文本生成一个简短、简洁的标题(最多10个字)。不要使用引号。只输出标题。",
|
||||
"content": "你是一个乐于助人的助手。请根据以下内容为 Excel 导出文件生成一个简短、简洁的文件名(最多10个字)。不要使用引号或文件扩展名。避免使用文件名中无效的特殊字符。只输出文件名。",
|
||||
},
|
||||
{"role": "user", "content": content[:2000]}, # 限制内容长度
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
response = await generate_chat_completion(request, payload, user_obj)
|
||||
# 定义生成任务
|
||||
async def generate_task():
|
||||
return await generate_chat_completion(request, payload, user_obj)
|
||||
|
||||
# 定义通知任务
|
||||
async def notification_task():
|
||||
# 立即发送首次通知
|
||||
if event_emitter:
|
||||
await self._send_notification(
|
||||
event_emitter,
|
||||
"info",
|
||||
"AI 正在为您生成文件名,请稍候...",
|
||||
)
|
||||
|
||||
# 之后每5秒通知一次
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
if event_emitter:
|
||||
await self._send_notification(
|
||||
event_emitter,
|
||||
"info",
|
||||
"文件名生成中,请耐心等待...",
|
||||
)
|
||||
|
||||
# 并发运行任务
|
||||
gen_future = asyncio.ensure_future(generate_task())
|
||||
notify_future = asyncio.ensure_future(notification_task())
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[gen_future, notify_future], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
# 如果生成完成,取消通知任务
|
||||
if not notify_future.done():
|
||||
notify_future.cancel()
|
||||
|
||||
# 获取结果
|
||||
if gen_future in done:
|
||||
response = gen_future.result()
|
||||
if response and "choices" in response:
|
||||
return response["choices"][0]["message"]["content"].strip()
|
||||
else:
|
||||
# 理论上不会发生,因为是 FIRST_COMPLETED 且我们取消了 notify
|
||||
await gen_future
|
||||
response = gen_future.result()
|
||||
if response and "choices" in response:
|
||||
return response["choices"][0]["message"]["content"].strip()
|
||||
|
||||
except Exception as e:
|
||||
print(f"生成标题时出错: {e}")
|
||||
if event_emitter:
|
||||
await self._send_notification(
|
||||
event_emitter,
|
||||
"warning",
|
||||
f"AI 文件名生成失败,将使用默认名称。错误: {str(e)}",
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -686,25 +781,52 @@ class Action:
|
||||
with pd.ExcelWriter(file_path, engine="xlsxwriter") as writer:
|
||||
workbook = writer.book
|
||||
|
||||
# 定义表头样式 - 居中对齐(符合中国规范)
|
||||
# OpenWebUI 风格主题配色
|
||||
HEADER_BG = "#1f2937" # 深灰色 (匹配 OpenWebUI 侧边栏)
|
||||
HEADER_FG = "#ffffff" # 白色文字
|
||||
ROW_ODD_BG = "#ffffff" # 奇数行白色
|
||||
ROW_EVEN_BG = "#f3f4f6" # 偶数行浅灰 (斑马纹)
|
||||
BORDER_COLOR = "#e5e7eb" # 浅色边框
|
||||
|
||||
# 表头样式 - 居中对齐
|
||||
header_format = workbook.add_format(
|
||||
{
|
||||
"bold": True,
|
||||
"font_size": 12,
|
||||
"font_color": "white",
|
||||
"bg_color": "#00abbd",
|
||||
"font_size": 11,
|
||||
"font_name": "Arial",
|
||||
"font_color": HEADER_FG,
|
||||
"bg_color": HEADER_BG,
|
||||
"border": 1,
|
||||
"align": "center", # 表头居中
|
||||
"border_color": BORDER_COLOR,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 文本单元格样式 - 左对齐
|
||||
# 文本单元格样式 - 左对齐 (奇数行)
|
||||
text_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"align": "left", # 文本左对齐
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 文本单元格样式 - 左对齐 (偶数行 - 斑马纹)
|
||||
text_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
@@ -712,15 +834,52 @@ class Action:
|
||||
|
||||
# 数值单元格样式 - 右对齐
|
||||
number_format = workbook.add_format(
|
||||
{"border": 1, "align": "right", "valign": "vcenter"} # 数值右对齐
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
number_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
# 整数格式 - 右对齐
|
||||
integer_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"align": "right", # 整数右对齐
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
integer_format_alt = workbook.add_format(
|
||||
{
|
||||
"num_format": "0",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
@@ -729,8 +888,25 @@ class Action:
|
||||
decimal_format = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"align": "right", # 小数右对齐
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
decimal_format_alt = workbook.add_format(
|
||||
{
|
||||
"num_format": "0.00",
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "right",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
@@ -738,8 +914,25 @@ class Action:
|
||||
# 日期格式 - 居中对齐
|
||||
date_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"align": "center", # 日期居中对齐
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
date_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
@@ -748,8 +941,24 @@ class Action:
|
||||
# 序号格式 - 居中对齐
|
||||
sequence_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"align": "center", # 序号居中对齐
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
|
||||
sequence_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "center",
|
||||
"valign": "vcenter",
|
||||
}
|
||||
)
|
||||
@@ -757,7 +966,25 @@ class Action:
|
||||
# 粗体单元格样式 (用于全单元格加粗)
|
||||
text_bold_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
"bold": True,
|
||||
}
|
||||
)
|
||||
|
||||
text_bold_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
@@ -768,7 +995,11 @@ class Action:
|
||||
# 斜体单元格样式 (用于全单元格斜体)
|
||||
text_italic_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_ODD_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
@@ -776,6 +1007,48 @@ class Action:
|
||||
}
|
||||
)
|
||||
|
||||
text_italic_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Arial",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": ROW_EVEN_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
"italic": True,
|
||||
}
|
||||
)
|
||||
|
||||
# 代码单元格样式 (用于行内代码高亮显示)
|
||||
CODE_BG = "#f0f0f0" # 代码浅灰背景
|
||||
text_code_format = workbook.add_format(
|
||||
{
|
||||
"font_name": "Consolas",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": CODE_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
text_code_format_alt = workbook.add_format(
|
||||
{
|
||||
"font_name": "Consolas",
|
||||
"font_size": 10,
|
||||
"border": 1,
|
||||
"border_color": BORDER_COLOR,
|
||||
"bg_color": CODE_BG,
|
||||
"align": "left",
|
||||
"valign": "vcenter",
|
||||
"text_wrap": True,
|
||||
}
|
||||
)
|
||||
|
||||
for i, table in enumerate(tables):
|
||||
try:
|
||||
table_data = table["data"]
|
||||
@@ -817,12 +1090,18 @@ class Action:
|
||||
|
||||
print(f"DataFrame created with columns: {list(df.columns)}")
|
||||
|
||||
# 修复pandas FutureWarning - 使用try-except替代errors='ignore'
|
||||
# 智能数据类型转换
|
||||
for col in df.columns:
|
||||
# 先尝试数字转换
|
||||
try:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# 尝试日期转换
|
||||
try:
|
||||
df[col] = pd.to_datetime(df[col], errors="raise")
|
||||
except (ValueError, TypeError):
|
||||
# 保持为字符串,使用 infer_objects 优化
|
||||
df[col] = df[col].infer_objects()
|
||||
|
||||
# 先写入数据(不包含表头)
|
||||
df.to_excel(
|
||||
@@ -834,21 +1113,25 @@ class Action:
|
||||
)
|
||||
worksheet = writer.sheets[sheet_name]
|
||||
|
||||
# 应用符合中国规范的格式化
|
||||
# 应用符合中国规范的格式化 (带斑马纹)
|
||||
formats = {
|
||||
"header": header_format,
|
||||
"text": [text_format, text_format_alt],
|
||||
"number": [number_format, number_format_alt],
|
||||
"integer": [integer_format, integer_format_alt],
|
||||
"decimal": [decimal_format, decimal_format_alt],
|
||||
"date": [date_format, date_format_alt],
|
||||
"sequence": [sequence_format, sequence_format_alt],
|
||||
"bold": [text_bold_format, text_bold_format_alt],
|
||||
"italic": [text_italic_format, text_italic_format_alt],
|
||||
"code": [text_code_format, text_code_format_alt],
|
||||
}
|
||||
self.apply_chinese_standard_formatting(
|
||||
worksheet,
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
text_bold_format,
|
||||
text_italic_format,
|
||||
formats,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -865,26 +1148,22 @@ class Action:
|
||||
df,
|
||||
headers,
|
||||
workbook,
|
||||
header_format,
|
||||
text_format,
|
||||
number_format,
|
||||
integer_format,
|
||||
decimal_format,
|
||||
date_format,
|
||||
sequence_format,
|
||||
text_bold_format=None,
|
||||
text_italic_format=None,
|
||||
formats,
|
||||
):
|
||||
"""
|
||||
应用符合中国官方表格规范的格式化
|
||||
- 表头: 居中对齐
|
||||
应用符合中国官方表格规范的格式化 (带斑马纹)
|
||||
- 表头: 居中对齐 (深色背景)
|
||||
- 数值: 右对齐
|
||||
- 文本: 左对齐
|
||||
- 日期: 居中对齐
|
||||
- 序号: 居中对齐
|
||||
- 斑马纹: 隔行变色
|
||||
- 支持全单元格 Markdown 粗体 (**text**) 和斜体 (*text*)
|
||||
"""
|
||||
try:
|
||||
# 从 formats 字典提取格式
|
||||
header_format = formats["header"]
|
||||
|
||||
# 1. 写入表头(居中对齐)
|
||||
print(f"Writing headers with Chinese standard alignment: {headers}")
|
||||
for col_idx, header in enumerate(headers):
|
||||
@@ -908,62 +1187,95 @@ class Action:
|
||||
else:
|
||||
column_types[col_idx] = "text"
|
||||
|
||||
# 3. 写入并格式化数据(根据类型使用不同对齐方式)
|
||||
# 3. 写入并格式化数据(带斑马纹)
|
||||
for row_idx, row in df.iterrows():
|
||||
# 确定奇偶行 (0-indexed, 所以 row 0 视觉上是第 1 行)
|
||||
is_alt_row = row_idx % 2 == 1 # 偶数索引 = 奇数行, 使用 alt 格式
|
||||
|
||||
for col_idx, value in enumerate(row):
|
||||
content_type = column_types.get(col_idx, "text")
|
||||
|
||||
# 根据内容类型选择格式
|
||||
# 根据内容类型和斑马纹选择格式
|
||||
fmt_idx = 1 if is_alt_row else 0
|
||||
|
||||
if content_type == "number":
|
||||
# 数值类型 - 右对齐
|
||||
if pd.api.types.is_numeric_dtype(df.iloc[:, col_idx]):
|
||||
if pd.api.types.is_integer_dtype(df.iloc[:, col_idx]):
|
||||
current_format = integer_format
|
||||
current_format = formats["integer"][fmt_idx]
|
||||
else:
|
||||
try:
|
||||
numeric_value = float(value)
|
||||
if numeric_value.is_integer():
|
||||
current_format = integer_format
|
||||
current_format = formats["integer"][fmt_idx]
|
||||
value = int(numeric_value)
|
||||
else:
|
||||
current_format = decimal_format
|
||||
current_format = formats["decimal"][fmt_idx]
|
||||
except (ValueError, TypeError):
|
||||
current_format = decimal_format
|
||||
current_format = formats["decimal"][fmt_idx]
|
||||
else:
|
||||
current_format = number_format
|
||||
current_format = formats["number"][fmt_idx]
|
||||
|
||||
elif content_type == "date":
|
||||
# 日期类型 - 居中对齐
|
||||
current_format = date_format
|
||||
current_format = formats["date"][fmt_idx]
|
||||
|
||||
elif content_type == "sequence":
|
||||
# 序号类型 - 居中对齐
|
||||
current_format = sequence_format
|
||||
current_format = formats["sequence"][fmt_idx]
|
||||
|
||||
else:
|
||||
# 文本类型 - 左对齐
|
||||
current_format = text_format
|
||||
current_format = formats["text"][fmt_idx]
|
||||
|
||||
if content_type == "text" and isinstance(value, str):
|
||||
# 检查是否全单元格加粗 (**text**)
|
||||
match_bold = re.fullmatch(r"\*\*(.+)\*\*", value.strip())
|
||||
# 检查是否全单元格斜体 (*text*)
|
||||
match_italic = re.fullmatch(r"\*(.+)\*", value.strip())
|
||||
# 检查是否全单元格代码 (`text`)
|
||||
match_code = re.fullmatch(r"`(.+)`", value.strip())
|
||||
|
||||
if match_bold:
|
||||
# 提取内容并应用粗体格式
|
||||
clean_value = match_bold.group(1)
|
||||
worksheet.write(
|
||||
row_idx + 1, col_idx, clean_value, text_bold_format
|
||||
row_idx + 1,
|
||||
col_idx,
|
||||
clean_value,
|
||||
formats["bold"][fmt_idx],
|
||||
)
|
||||
elif match_italic:
|
||||
# 提取内容并应用斜体格式
|
||||
clean_value = match_italic.group(1)
|
||||
worksheet.write(
|
||||
row_idx + 1, col_idx, clean_value, text_italic_format
|
||||
row_idx + 1,
|
||||
col_idx,
|
||||
clean_value,
|
||||
formats["italic"][fmt_idx],
|
||||
)
|
||||
elif match_code:
|
||||
# 提取内容并应用代码格式 (高亮显示)
|
||||
clean_value = match_code.group(1)
|
||||
worksheet.write(
|
||||
row_idx + 1,
|
||||
col_idx,
|
||||
clean_value,
|
||||
formats["code"][fmt_idx],
|
||||
)
|
||||
else:
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
# 移除部分 Markdown 格式符号 (Excel 无法渲染部分格式)
|
||||
# 移除粗体标记 **text** -> text
|
||||
clean_value = re.sub(r"\*\*(.+?)\*\*", r"\1", value)
|
||||
# 移除斜体标记 *text* -> text (但不影响 ** 内部的内容)
|
||||
clean_value = re.sub(
|
||||
r"(?<!\*)\*([^*]+)\*(?!\*)", r"\1", clean_value
|
||||
)
|
||||
# 移除代码标记 `text` -> text
|
||||
clean_value = re.sub(r"`(.+?)`", r"\1", clean_value)
|
||||
worksheet.write(
|
||||
row_idx + 1, col_idx, clean_value, current_format
|
||||
)
|
||||
else:
|
||||
worksheet.write(row_idx + 1, col_idx, value, current_format)
|
||||
|
||||
@@ -48,3 +48,9 @@ GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.2.4
|
||||
|
||||
- Removed debug messages from output
|
||||
|
||||
@@ -48,3 +48,9 @@ GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.2.4
|
||||
|
||||
- 移除输出中的调试信息
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Flash Card
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.2.2
|
||||
version: 0.2.4
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5Z29uIHBvaW50cz0iMTIgMiAyIDcgMTIgMTIgMjIgNyAxMiAyIi8+PHBvbHlsaW5lIHBvaW50cz0iMiAxNyAxMiAyMiAyMiAxNyIvPjxwb2x5bGluZSBwb2ludHM9IjIgMTIgMTIgMTcgMjIgMTIiLz48L3N2Zz4=
|
||||
description: Quickly generates beautiful flashcards from text, extracting key points and categories.
|
||||
"""
|
||||
@@ -147,7 +147,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "Assistant" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} Message {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
return body
|
||||
|
||||
@@ -3,7 +3,7 @@ title: 闪记卡 (Flash Card)
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.2.2
|
||||
version: 0.2.4
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5Z29uIHBvaW50cz0iMTIgMiAyIDcgMTIgMTIgMjIgNyAxMiAyIi8+PHBvbHlsaW5lIHBvaW50cz0iMiAxNyAxMiAyMiAyMiAxNyIvPjxwb2x5bGluZSBwb2ludHM9IjIgMTIgMTIgMTcgMjIgMTIiLz48L3N2Zz4=
|
||||
description: 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。
|
||||
"""
|
||||
@@ -144,7 +144,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "助手" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} 消息 {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
return body
|
||||
@@ -63,3 +63,9 @@ data
|
||||
## 📄 License
|
||||
|
||||
MIT License
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.3.2
|
||||
|
||||
- Removed debug messages from output
|
||||
|
||||
@@ -63,3 +63,9 @@ data
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.3.2
|
||||
|
||||
- 移除输出中的调试信息
|
||||
|
||||
@@ -3,7 +3,7 @@ title: 📊 Smart Infographic (AntV)
|
||||
author: jeff
|
||||
author_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
|
||||
version: 1.3.0
|
||||
version: 1.3.2
|
||||
description: AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.
|
||||
"""
|
||||
|
||||
@@ -961,9 +961,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "Assistant" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(
|
||||
f"[{role_label} Message {i}]\n{text_content}"
|
||||
)
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("Unable to get valid user message content.")
|
||||
|
||||
@@ -3,7 +3,7 @@ title: 📊 智能信息图 (AntV Infographic)
|
||||
author: jeff
|
||||
author_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogIDxsaW5lIHgxPSIxMiIgeTE9IjIwIiB4Mj0iMTIiIHkyPSIxMCIgLz4KICA8bGluZSB4MT0iMTgiIHkxPSIyMCIgeDI9IjE4IiB5Mj0iNCIgLz4KICA8bGluZSB4MT0iNiIgeTE9IjIwIiB4Mj0iNiIgeTI9IjE2IiAvPgo8L3N2Zz4=
|
||||
version: 1.3.0
|
||||
version: 1.3.2
|
||||
description: 基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。
|
||||
"""
|
||||
|
||||
@@ -1026,7 +1026,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "助手" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} 消息 {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
@@ -24,7 +24,7 @@ if not API_KEY or not BASE_URL:
|
||||
sys.exit(1)
|
||||
|
||||
# =================================================================
|
||||
# Prompts (Extracted from 信息图.py)
|
||||
# Prompts (Extracted from infographic_cn.py)
|
||||
# =================================================================
|
||||
|
||||
SYSTEM_PROMPT_INFOGRAPHIC_ASSISTANT = """
|
||||
|
||||
170
plugins/actions/js-render-poc/README.md
Normal file
170
plugins/actions/js-render-poc/README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 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
|
||||
174
plugins/actions/js-render-poc/README_CN.md
Normal file
174
plugins/actions/js-render-poc/README_CN.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 信息图转 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
|
||||
592
plugins/actions/js-render-poc/infographic_markdown.py
Normal file
592
plugins/actions/js-render-poc/infographic_markdown.py
Normal file
@@ -0,0 +1,592 @@
|
||||
"""
|
||||
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
|
||||
592
plugins/actions/js-render-poc/infographic_markdown_cn.py
Normal file
592
plugins/actions/js-render-poc/infographic_markdown_cn.py
Normal file
@@ -0,0 +1,592 @@
|
||||
"""
|
||||
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
|
||||
257
plugins/actions/js-render-poc/js_render_poc.py
Normal file
257
plugins/actions/js-render-poc/js_render_poc.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,6 +1,6 @@
|
||||
# Smart Mind Map - Mind Mapping Generation Plugin
|
||||
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.8.0 | **License:** MIT
|
||||
**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.9.1 | **License:** MIT
|
||||
|
||||
> **Important**: To ensure the maintainability and usability of all plugins, each plugin should be accompanied by clear and comprehensive documentation to ensure its functionality, configuration, and usage are well explained.
|
||||
|
||||
@@ -20,6 +20,7 @@ Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes
|
||||
- ✅ **Real-time Rendering**: Renders mind maps directly in the chat interface without navigation
|
||||
- ✅ **Export Capabilities**: Supports PNG, SVG code, and Markdown source export
|
||||
- ✅ **Customizable Configuration**: Configurable LLM model, minimum text length, and other parameters
|
||||
- ✅ **Image Output Mode**: Generate static SVG images embedded directly in Markdown (no interactive HTML)
|
||||
|
||||
---
|
||||
|
||||
@@ -39,7 +40,7 @@ Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes
|
||||
|
||||
### 1. Plugin Installation
|
||||
|
||||
1. Download the `思维导图.py` file to your local computer
|
||||
1. Download the `smart_mind_map_cn.py` file to your local computer
|
||||
2. In OpenWebUI Admin Settings, find the "Plugins" section
|
||||
3. Select "Actions" type
|
||||
4. Upload the downloaded file
|
||||
@@ -80,6 +81,7 @@ You can adjust the following parameters in the plugin's settings (Valves):
|
||||
| `MIN_TEXT_LENGTH` | `100` | Minimum text length (in characters) required for mind map analysis. Text that's too short cannot generate valid mind maps. |
|
||||
| `CLEAR_PREVIOUS_HTML` | `false` | Whether to clear previous plugin-generated HTML content when generating a new mind map. |
|
||||
| `MESSAGE_COUNT` | `1` | Number of recent messages to use for mind map generation (1-5). |
|
||||
| `OUTPUT_MODE` | `html` | Output mode: `html` for interactive HTML (default), or `image` to embed as static Markdown image. |
|
||||
|
||||
---
|
||||
|
||||
@@ -277,7 +279,37 @@ This plugin uses only OpenWebUI's built-in dependencies. **No additional package
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.8.0 (Current Version)
|
||||
### v0.9.1
|
||||
|
||||
**New Feature: Image Output Mode**
|
||||
|
||||
- Added `OUTPUT_MODE` configuration parameter with two options:
|
||||
- `html` (default): Interactive HTML mind map with full control panel
|
||||
- `image`: Static SVG image embedded directly in Markdown (uploaded to `/api/v1/files`)
|
||||
- Image mode features:
|
||||
- Auto-responsive width (adapts to chat container)
|
||||
- Automatic theme detection (light/dark)
|
||||
- Persistent storage via Chat API (survives page refresh)
|
||||
- Efficient file storage (no huge base64 strings in chat history)
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Implemented robust Chat API update mechanism with retry logic
|
||||
- Fixed message persistence using both `messages[]` and `history.messages`
|
||||
- Added Event API for immediate frontend updates
|
||||
- Removed unnecessary `SVG_WIDTH` and `SVG_HEIGHT` parameters (now auto-calculated)
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
- Image mode uses `__event_call__` to execute JavaScript in the browser
|
||||
- SVG is rendered offline, converted to Blob, and uploaded to OpenWebUI Files API
|
||||
- Updates chat message with `/api/v1/files/{id}/content` URL via OpenWebUI Backend-Controlled API flow
|
||||
|
||||
### v0.8.2
|
||||
|
||||
- Removed debug messages from output
|
||||
|
||||
### v0.8.0 (Previous Version)
|
||||
|
||||
**Major Features:**
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 思维导图 - 思维导图生成插件
|
||||
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.8.0 | **许可证:** MIT
|
||||
**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.9.1 | **许可证:** MIT
|
||||
|
||||
> **重要提示**:为了确保所有插件的可维护性和易用性,每个插件都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- ✅ **实时渲染**:在聊天界面中直接渲染思维导图,无需跳转
|
||||
- ✅ **导出功能**:支持 PNG、SVG 代码和 Markdown 源码导出
|
||||
- ✅ **自定义配置**:可配置 LLM 模型、最小文本长度等参数
|
||||
- ✅ **图片输出模式**:生成静态 SVG 图片直接嵌入 Markdown(无交互式 HTML)
|
||||
|
||||
---
|
||||
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
### 1. 插件安装
|
||||
|
||||
1. 下载 `思维导图.py` 文件到本地
|
||||
1. 下载 `smart_mind_map_cn.py` 文件到本地
|
||||
2. 在 OpenWebUI 管理员设置中找到"插件"(Plugins)部分
|
||||
3. 选择"动作"(Actions)类型
|
||||
4. 上传下载的文件
|
||||
@@ -80,6 +81,7 @@
|
||||
| `MIN_TEXT_LENGTH` | `100` | 进行思维导图分析所需的最小文本长度(字符数)。文本过短将无法生成有效的导图。 |
|
||||
| `CLEAR_PREVIOUS_HTML` | `false` | 在生成新的思维导图时,是否清除之前由插件生成的 HTML 内容。 |
|
||||
| `MESSAGE_COUNT` | `1` | 用于生成思维导图的最近消息数量(1-5)。 |
|
||||
| `OUTPUT_MODE` | `html` | 输出模式:`html` 为交互式 HTML(默认),`image` 为嵌入静态 Markdown 图片。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -277,7 +279,37 @@
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.8.0(当前版本)
|
||||
### v0.9.1
|
||||
|
||||
**新功能:图片输出模式**
|
||||
|
||||
- 新增 `OUTPUT_MODE` 配置参数,支持两种模式:
|
||||
- `html`(默认):交互式 HTML 思维导图,带完整控制面板
|
||||
- `image`:静态 SVG 图片直接嵌入 Markdown(上传至 `/api/v1/files`)
|
||||
- 图片模式特性:
|
||||
- 自动响应式宽度(适应聊天容器)
|
||||
- 自动主题检测(亮色/暗色)
|
||||
- 通过 Chat API 持久化存储(刷新页面后保留)
|
||||
- 高效文件存储(聊天记录中无超长 Base64 字符串)
|
||||
|
||||
**改进项:**
|
||||
|
||||
- 实现健壮的 Chat API 更新机制,带重试逻辑
|
||||
- 修复消息持久化,同时更新 `messages[]` 和 `history.messages`
|
||||
- 添加 Event API 实现即时前端更新
|
||||
- 移除不必要的 `SVG_WIDTH` 和 `SVG_HEIGHT` 参数(现已自动计算)
|
||||
|
||||
**技术细节:**
|
||||
|
||||
- 图片模式使用 `__event_call__` 在浏览器中执行 JavaScript
|
||||
- SVG 离屏渲染,转换为 Blob,并上传至 OpenWebUI Files API
|
||||
- 通过 OpenWebUI Backend-Controlled API 流程更新聊天消息为 `/api/v1/files/{id}/content` URL
|
||||
|
||||
### v0.8.2
|
||||
|
||||
- 移除输出中的调试信息
|
||||
|
||||
### v0.8.0 (Previous Version)
|
||||
|
||||
**主要功能:**
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Smart Mind Map
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.8.0
|
||||
version: 0.9.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
|
||||
description: Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.
|
||||
"""
|
||||
@@ -13,7 +13,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Callable, Awaitable, Dict, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import Request
|
||||
@@ -786,6 +786,10 @@ class Action:
|
||||
default=1,
|
||||
description="Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context.",
|
||||
)
|
||||
OUTPUT_MODE: str = Field(
|
||||
default="html",
|
||||
description="Output mode: 'html' for interactive HTML (default), or 'image' to embed as Markdown image.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
@@ -814,6 +818,46 @@ class Action:
|
||||
"user_language": user_data.get("language", "en-US"),
|
||||
}
|
||||
|
||||
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""Extract chat_id from body or metadata"""
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
chat_id = body_metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""Extract message_id from body or metadata"""
|
||||
if isinstance(body, dict):
|
||||
message_id = body.get("id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
message_id = body_metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_markdown_syntax(self, llm_output: str) -> str:
|
||||
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
@@ -901,14 +945,391 @@ class Action:
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def _generate_image_js_code(
|
||||
self,
|
||||
unique_id: str,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
markdown_syntax: str,
|
||||
) -> str:
|
||||
"""Generate JavaScript code for frontend SVG rendering and image embedding"""
|
||||
|
||||
# Escape the syntax for JS embedding
|
||||
syntax_escaped = (
|
||||
markdown_syntax.replace("\\", "\\\\")
|
||||
.replace("`", "\\`")
|
||||
.replace("${", "\\${")
|
||||
.replace("</script>", "<\\/script>")
|
||||
)
|
||||
|
||||
return f"""
|
||||
(async function() {{
|
||||
const uniqueId = "{unique_id}";
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
const defaultWidth = 1200;
|
||||
const defaultHeight = 800;
|
||||
|
||||
// Theme detection - check parent document for OpenWebUI theme
|
||||
const detectTheme = () => {{
|
||||
try {{
|
||||
// 1. Check parent document's html/body class or data-theme
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const htmlClass = html ? html.className : '';
|
||||
const bodyClass = body ? body.className : '';
|
||||
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
||||
|
||||
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
|
||||
return 'dark';
|
||||
}}
|
||||
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
|
||||
return 'light';
|
||||
}}
|
||||
|
||||
// 2. Check meta theme-color
|
||||
const metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
if (metas.length > 0) {{
|
||||
const color = metas[metas.length - 1].content.trim();
|
||||
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
|
||||
if (m) {{
|
||||
const hex = m[1];
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return luma < 0.5 ? 'dark' : 'light';
|
||||
}}
|
||||
}}
|
||||
|
||||
// 3. Check system preference
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
|
||||
return 'dark';
|
||||
}}
|
||||
|
||||
return 'light';
|
||||
}} catch (e) {{
|
||||
return 'light';
|
||||
}}
|
||||
}};
|
||||
|
||||
const currentTheme = detectTheme();
|
||||
console.log("[MindMap Image] Detected theme:", currentTheme);
|
||||
|
||||
// Theme-based colors
|
||||
const colors = currentTheme === 'dark' ? {{
|
||||
background: '#1f2937',
|
||||
text: '#e5e7eb',
|
||||
link: '#94a3b8',
|
||||
nodeStroke: '#64748b'
|
||||
}} : {{
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
link: '#546e7a',
|
||||
nodeStroke: '#94a3b8'
|
||||
}};
|
||||
|
||||
// Auto-detect chat container width for responsive sizing
|
||||
let svgWidth = defaultWidth;
|
||||
let svgHeight = defaultHeight;
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
if (chatContainer) {{
|
||||
const containerWidth = chatContainer.clientWidth;
|
||||
if (containerWidth > 100) {{
|
||||
// Use container width with some padding (90% of container)
|
||||
svgWidth = Math.floor(containerWidth * 0.9);
|
||||
// Maintain aspect ratio based on default dimensions
|
||||
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
|
||||
console.log("[MindMap Image] Auto-detected container width:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
|
||||
}}
|
||||
}}
|
||||
|
||||
console.log("[MindMap Image] Starting render...");
|
||||
console.log("[MindMap Image] chatId:", chatId, "messageId:", messageId);
|
||||
|
||||
try {{
|
||||
// Load D3 if not loaded
|
||||
if (typeof d3 === 'undefined') {{
|
||||
console.log("[MindMap Image] Loading D3...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
}}
|
||||
|
||||
// Load markmap-lib if not loaded
|
||||
if (!window.markmap || !window.markmap.Transformer) {{
|
||||
console.log("[MindMap Image] Loading markmap-lib...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/markmap-lib@0.17';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
}}
|
||||
|
||||
// Load markmap-view if not loaded
|
||||
if (!window.markmap || !window.markmap.Markmap) {{
|
||||
console.log("[MindMap Image] Loading markmap-view...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/markmap-view@0.17';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
}}
|
||||
|
||||
const {{ Transformer, Markmap }} = window.markmap;
|
||||
|
||||
// Get markdown syntax
|
||||
let syntaxContent = `{syntax_escaped}`;
|
||||
console.log("[MindMap Image] Syntax length:", syntaxContent.length);
|
||||
|
||||
// Create offscreen container
|
||||
const container = document.createElement('div');
|
||||
container.id = 'mindmap-offscreen-' + uniqueId;
|
||||
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create SVG element
|
||||
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svgEl.setAttribute('width', svgWidth);
|
||||
svgEl.setAttribute('height', svgHeight);
|
||||
svgEl.style.width = svgWidth + 'px';
|
||||
svgEl.style.height = svgHeight + 'px';
|
||||
svgEl.style.backgroundColor = colors.background;
|
||||
container.appendChild(svgEl);
|
||||
|
||||
// Transform markdown to tree
|
||||
const transformer = new Transformer();
|
||||
const {{ root }} = transformer.transform(syntaxContent);
|
||||
|
||||
// Create markmap instance
|
||||
const options = {{
|
||||
autoFit: true,
|
||||
initialExpandLevel: Infinity,
|
||||
zoom: false,
|
||||
pan: false
|
||||
}};
|
||||
|
||||
console.log("[MindMap Image] Rendering markmap...");
|
||||
const markmapInstance = Markmap.create(svgEl, options, root);
|
||||
|
||||
// Wait for render to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
markmapInstance.fit();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Clone and prepare SVG for export
|
||||
const clonedSvg = svgEl.cloneNode(true);
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
// Add background rect with theme color
|
||||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bgRect.setAttribute('width', '100%');
|
||||
bgRect.setAttribute('height', '100%');
|
||||
bgRect.setAttribute('fill', colors.background);
|
||||
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
||||
|
||||
// Add inline styles with theme colors
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = `
|
||||
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
|
||||
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
|
||||
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
|
||||
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
|
||||
strong {{ font-weight: 700; }}
|
||||
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
|
||||
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
|
||||
`;
|
||||
clonedSvg.insertBefore(style, bgRect.nextSibling);
|
||||
|
||||
// Convert foreignObject to text for better compatibility
|
||||
const foreignObjects = clonedSvg.querySelectorAll('foreignObject');
|
||||
foreignObjects.forEach(fo => {{
|
||||
const text = fo.textContent || '';
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
textEl.setAttribute('x', fo.getAttribute('x') || '0');
|
||||
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
|
||||
textEl.setAttribute('fill', colors.text);
|
||||
textEl.setAttribute('font-family', 'sans-serif');
|
||||
textEl.setAttribute('font-size', '14');
|
||||
textEl.textContent = text.trim();
|
||||
g.appendChild(textEl);
|
||||
fo.parentNode.replaceChild(g, fo);
|
||||
}});
|
||||
|
||||
// Serialize SVG to string
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
|
||||
// Cleanup container
|
||||
document.body.removeChild(container);
|
||||
|
||||
// Convert SVG string to Blob
|
||||
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
|
||||
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
|
||||
|
||||
// Upload file to OpenWebUI API
|
||||
console.log("[MindMap Image] Uploading SVG file...");
|
||||
const token = localStorage.getItem("token");
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadResponse = await fetch('/api/v1/files/', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Authorization': `Bearer ${{token}}`
|
||||
}},
|
||||
body: formData
|
||||
}});
|
||||
|
||||
if (!uploadResponse.ok) {{
|
||||
throw new Error(`Upload failed: ${{uploadResponse.statusText}}`);
|
||||
}}
|
||||
|
||||
const fileData = await uploadResponse.json();
|
||||
const fileId = fileData.id;
|
||||
const imageUrl = `/api/v1/files/${{fileId}}/content`;
|
||||
|
||||
console.log("[MindMap Image] File uploaded, ID:", fileId);
|
||||
|
||||
// Generate markdown image with file URL
|
||||
const markdownImage = ``;
|
||||
|
||||
// Update message via API
|
||||
if (chatId && messageId) {{
|
||||
|
||||
// Helper function with retry logic
|
||||
const fetchWithRetry = async (url, options, retries = 3) => {{
|
||||
for (let i = 0; i < retries; i++) {{
|
||||
try {{
|
||||
const response = await fetch(url, options);
|
||||
if (response.ok) return response;
|
||||
if (i < retries - 1) {{
|
||||
console.log(`[MindMap Image] Retry ${{i + 1}}/${{retries}} for ${{url}}`);
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||||
}}
|
||||
}} catch (e) {{
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||||
}}
|
||||
}}
|
||||
return null;
|
||||
}};
|
||||
|
||||
// Get current chat data
|
||||
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "GET",
|
||||
headers: {{ "Authorization": `Bearer ${{token}}` }}
|
||||
}});
|
||||
|
||||
if (!getResponse.ok) {{
|
||||
throw new Error("Failed to get chat data: " + getResponse.status);
|
||||
}}
|
||||
|
||||
const chatData = await getResponse.json();
|
||||
let updatedMessages = [];
|
||||
let newContent = "";
|
||||
|
||||
if (chatData.chat && chatData.chat.messages) {{
|
||||
updatedMessages = chatData.chat.messages.map(m => {{
|
||||
if (m.id === messageId) {{
|
||||
const originalContent = m.content || "";
|
||||
// Remove existing mindmap images (both base64 and file URL patterns)
|
||||
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
|
||||
let cleanedContent = originalContent.replace(mindmapPattern, "");
|
||||
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
||||
// Append new image
|
||||
newContent = cleanedContent + "\\n\\n" + markdownImage;
|
||||
|
||||
// Critical: Update content in both messages array AND history object
|
||||
// The history object is the source of truth for the database
|
||||
if (chatData.chat.history && chatData.chat.history.messages) {{
|
||||
if (chatData.chat.history.messages[messageId]) {{
|
||||
chatData.chat.history.messages[messageId].content = newContent;
|
||||
}}
|
||||
}}
|
||||
|
||||
return {{ ...m, content: newContent }};
|
||||
}}
|
||||
return m;
|
||||
}});
|
||||
}}
|
||||
|
||||
if (!newContent) {{
|
||||
console.warn("[MindMap Image] Could not find message to update");
|
||||
return;
|
||||
}}
|
||||
|
||||
// Try to update frontend display via event API (optional, may not exist in all versions)
|
||||
try {{
|
||||
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
type: "chat:message",
|
||||
data: {{ content: newContent }}
|
||||
}})
|
||||
}});
|
||||
}} catch (eventErr) {{
|
||||
// Event API is optional, continue with persistence
|
||||
console.log("[MindMap Image] Event API not available, continuing...");
|
||||
}}
|
||||
|
||||
// Persist to database by updating the entire chat object
|
||||
// This follows the OpenWebUI Backend-Controlled API Flow
|
||||
const updatePayload = {{
|
||||
chat: {{
|
||||
...chatData.chat,
|
||||
messages: updatedMessages
|
||||
// history is already updated in-place above
|
||||
}}
|
||||
}};
|
||||
|
||||
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify(updatePayload)
|
||||
}});
|
||||
|
||||
if (persistResponse && persistResponse.ok) {{
|
||||
console.log("[MindMap Image] ✅ Message persisted successfully!");
|
||||
}} else {{
|
||||
console.error("[MindMap Image] ❌ Failed to persist message after retries");
|
||||
}}
|
||||
}} else {{
|
||||
console.warn("[MindMap Image] ⚠️ Missing chatId or messageId, cannot persist");
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
console.error("[MindMap Image] Error:", error);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: Smart Mind Map (v0.8.0) started")
|
||||
logger.info("Action: Smart Mind Map (v0.9.1) started")
|
||||
user_ctx = self._get_user_context(__user__)
|
||||
user_language = user_ctx["user_language"]
|
||||
user_name = user_ctx["user_name"]
|
||||
@@ -960,7 +1381,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "Assistant" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} Message {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
error_message = "Unable to retrieve valid user message content."
|
||||
@@ -1090,6 +1511,45 @@ class Action:
|
||||
user_language,
|
||||
)
|
||||
|
||||
# Check output mode
|
||||
if self.valves.OUTPUT_MODE == "image":
|
||||
# Image mode: use JavaScript to render and embed as Markdown image
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
"Smart Mind Map: Rendering image...",
|
||||
False,
|
||||
)
|
||||
|
||||
if __event_call__:
|
||||
js_code = self._generate_image_js_code(
|
||||
unique_id=unique_id,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
markdown_syntax=markdown_syntax,
|
||||
)
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "Smart Mind Map: Image generated!", True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"Mind map image has been generated, {user_name}!",
|
||||
"success",
|
||||
)
|
||||
logger.info("Action: Smart Mind Map (v0.9.1) completed in image mode")
|
||||
return body
|
||||
|
||||
# HTML mode (default): embed as HTML block
|
||||
html_embed_tag = f"```html\n{final_html}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
|
||||
@@ -1101,7 +1561,7 @@ class Action:
|
||||
f"Mind map has been generated, {user_name}!",
|
||||
"success",
|
||||
)
|
||||
logger.info("Action: Smart Mind Map (v0.8.0) completed successfully")
|
||||
logger.info("Action: Smart Mind Map (v0.9.1) completed in HTML mode")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Smart Mind Map processing failed: {str(e)}"
|
||||
|
||||
@@ -3,7 +3,7 @@ title: 思维导图
|
||||
author: Fu-Jie
|
||||
author_url: https://github.com/Fu-Jie
|
||||
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
||||
version: 0.8.0
|
||||
version: 0.9.1
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
|
||||
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
|
||||
"""
|
||||
@@ -13,7 +13,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Callable, Awaitable, Dict, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import Request
|
||||
@@ -443,7 +443,7 @@ SCRIPT_TEMPLATE_MINDMAP = """
|
||||
|
||||
const markdownContent = sourceEl.textContent.trim();
|
||||
if (!markdownContent) {
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图:缺少有效内容。</div>';
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图:缺少有效内容。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ SCRIPT_TEMPLATE_MINDMAP = """
|
||||
|
||||
}).catch((error) => {
|
||||
console.error('Markmap loading error:', error);
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 资源加载失败,请稍后重试。</div>';
|
||||
containerEl.innerHTML = '<div class="error-message">⚠️ 资源加载失败,请稍后重试。</div>';
|
||||
});
|
||||
};
|
||||
|
||||
@@ -771,19 +771,23 @@ class Action:
|
||||
)
|
||||
MODEL_ID: str = Field(
|
||||
default="",
|
||||
description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。",
|
||||
description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。",
|
||||
)
|
||||
MIN_TEXT_LENGTH: int = Field(
|
||||
default=100,
|
||||
description="进行思维导图分析所需的最小文本长度(字符数)。",
|
||||
description="进行思维导图分析所需的最小文本长度(字符数)。",
|
||||
)
|
||||
CLEAR_PREVIOUS_HTML: bool = Field(
|
||||
default=False,
|
||||
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
|
||||
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
|
||||
)
|
||||
MESSAGE_COUNT: int = Field(
|
||||
default=1,
|
||||
description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
|
||||
description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
|
||||
)
|
||||
OUTPUT_MODE: str = Field(
|
||||
default="html",
|
||||
description="输出模式: 'html' 为交互式HTML(默认),'image' 为嵌入Markdown图片。",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
@@ -813,14 +817,52 @@ class Action:
|
||||
"user_language": user_data.get("language", "zh-CN"),
|
||||
}
|
||||
|
||||
def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""从 body 或 metadata 中提取 chat_id"""
|
||||
if isinstance(body, dict):
|
||||
chat_id = body.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
chat_id = body_metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = metadata.get("chat_id")
|
||||
if isinstance(chat_id, str) and chat_id.strip():
|
||||
return chat_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str:
|
||||
"""从 body 或 metadata 中提取 message_id"""
|
||||
if isinstance(body, dict):
|
||||
message_id = body.get("id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
body_metadata = body.get("metadata", {})
|
||||
if isinstance(body_metadata, dict):
|
||||
message_id = body_metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
message_id = metadata.get("message_id")
|
||||
if isinstance(message_id, str) and message_id.strip():
|
||||
return message_id.strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_markdown_syntax(self, llm_output: str) -> str:
|
||||
match = re.search(r"```markdown\s*(.*?)\s*```", llm_output, re.DOTALL)
|
||||
if match:
|
||||
extracted_content = match.group(1).strip()
|
||||
else:
|
||||
logger.warning(
|
||||
"LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。"
|
||||
)
|
||||
logger.warning("LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。")
|
||||
extracted_content = llm_output.strip()
|
||||
return extracted_content.replace("</script>", "<\\/script>")
|
||||
|
||||
@@ -844,7 +886,7 @@ class Action:
|
||||
return re.sub(pattern, "", content).strip()
|
||||
|
||||
def _extract_text_content(self, content) -> str:
|
||||
"""从消息内容中提取文本,支持多模态消息格式"""
|
||||
"""从消息内容中提取文本,支持多模态消息格式"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, list):
|
||||
@@ -867,7 +909,7 @@ class Action:
|
||||
user_language: str = "zh-CN",
|
||||
) -> str:
|
||||
"""
|
||||
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
|
||||
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
|
||||
"""
|
||||
if (
|
||||
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
|
||||
@@ -900,14 +942,392 @@ class Action:
|
||||
|
||||
return base_html.strip()
|
||||
|
||||
def _generate_image_js_code(
|
||||
self,
|
||||
unique_id: str,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
markdown_syntax: str,
|
||||
) -> str:
|
||||
"""生成用于前端 SVG 渲染和图片嵌入的 JavaScript 代码"""
|
||||
|
||||
# 转义语法以便嵌入 JS
|
||||
syntax_escaped = (
|
||||
markdown_syntax.replace("\\", "\\\\")
|
||||
.replace("`", "\\`")
|
||||
.replace("${", "\\${")
|
||||
.replace("</script>", "<\\/script>")
|
||||
)
|
||||
|
||||
return f"""
|
||||
(async function() {{
|
||||
const uniqueId = "{unique_id}";
|
||||
const chatId = "{chat_id}";
|
||||
const messageId = "{message_id}";
|
||||
const defaultWidth = 1200;
|
||||
const defaultHeight = 800;
|
||||
|
||||
// 主题检测 - 检查 OpenWebUI 当前主题
|
||||
const detectTheme = () => {{
|
||||
try {{
|
||||
// 1. 检查 html/body 的 class 或 data-theme 属性
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const htmlClass = html ? html.className : '';
|
||||
const bodyClass = body ? body.className : '';
|
||||
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
||||
|
||||
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
|
||||
return 'dark';
|
||||
}}
|
||||
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
|
||||
return 'light';
|
||||
}}
|
||||
|
||||
// 2. 检查 meta theme-color
|
||||
const metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
if (metas.length > 0) {{
|
||||
const color = metas[metas.length - 1].content.trim();
|
||||
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
|
||||
if (m) {{
|
||||
const hex = m[1];
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return luma < 0.5 ? 'dark' : 'light';
|
||||
}}
|
||||
}}
|
||||
|
||||
// 3. 检查系统偏好
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
|
||||
return 'dark';
|
||||
}}
|
||||
|
||||
return 'light';
|
||||
}} catch (e) {{
|
||||
return 'light';
|
||||
}}
|
||||
}};
|
||||
|
||||
const currentTheme = detectTheme();
|
||||
console.log("[思维导图图片] 检测到主题:", currentTheme);
|
||||
|
||||
// 基于主题的颜色配置
|
||||
const colors = currentTheme === 'dark' ? {{
|
||||
background: '#1f2937',
|
||||
text: '#e5e7eb',
|
||||
link: '#94a3b8',
|
||||
nodeStroke: '#64748b'
|
||||
}} : {{
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
link: '#546e7a',
|
||||
nodeStroke: '#94a3b8'
|
||||
}};
|
||||
|
||||
// 自动检测聊天容器宽度以实现自适应
|
||||
let svgWidth = defaultWidth;
|
||||
let svgHeight = defaultHeight;
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
if (chatContainer) {{
|
||||
const containerWidth = chatContainer.clientWidth;
|
||||
if (containerWidth > 100) {{
|
||||
// 使用容器宽度的90%(留出边距)
|
||||
svgWidth = Math.floor(containerWidth * 0.9);
|
||||
// 根据默认尺寸保持宽高比
|
||||
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
|
||||
console.log("[思维导图图片] 自动检测容器宽度:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
|
||||
}}
|
||||
}}
|
||||
|
||||
console.log("[思维导图图片] 开始渲染...");
|
||||
console.log("[思维导图图片] chatId:", chatId, "messageId:", messageId);
|
||||
|
||||
try {{
|
||||
// 加载 D3
|
||||
if (typeof d3 === 'undefined') {{
|
||||
console.log("[思维导图图片] 正在加载 D3...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
}}
|
||||
|
||||
// 加载 markmap-lib
|
||||
if (!window.markmap || !window.markmap.Transformer) {{
|
||||
console.log("[思维导图图片] 正在加载 markmap-lib...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/markmap-lib@0.17';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
}}
|
||||
|
||||
// 加载 markmap-view
|
||||
if (!window.markmap || !window.markmap.Markmap) {{
|
||||
console.log("[思维导图图片] 正在加载 markmap-view...");
|
||||
await new Promise((resolve, reject) => {{
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/markmap-view@0.17';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
}});
|
||||
}}
|
||||
|
||||
const {{ Transformer, Markmap }} = window.markmap;
|
||||
|
||||
// 获取 markdown 语法
|
||||
let syntaxContent = `{syntax_escaped}`;
|
||||
console.log("[思维导图图片] 语法长度:", syntaxContent.length);
|
||||
|
||||
// 创建离屏容器
|
||||
const container = document.createElement('div');
|
||||
container.id = 'mindmap-offscreen-' + uniqueId;
|
||||
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 创建 SVG 元素
|
||||
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svgEl.setAttribute('width', svgWidth);
|
||||
svgEl.setAttribute('height', svgHeight);
|
||||
svgEl.style.width = svgWidth + 'px';
|
||||
svgEl.style.height = svgHeight + 'px';
|
||||
svgEl.style.backgroundColor = colors.background;
|
||||
container.appendChild(svgEl);
|
||||
|
||||
// 将 markdown 转换为树结构
|
||||
const transformer = new Transformer();
|
||||
const {{ root }} = transformer.transform(syntaxContent);
|
||||
|
||||
// 创建 markmap 实例
|
||||
const options = {{
|
||||
autoFit: true,
|
||||
initialExpandLevel: Infinity,
|
||||
zoom: false,
|
||||
pan: false
|
||||
}};
|
||||
|
||||
console.log("[思维导图图片] 正在渲染 markmap...");
|
||||
const markmapInstance = Markmap.create(svgEl, options, root);
|
||||
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
markmapInstance.fit();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 克隆并准备 SVG 导出
|
||||
const clonedSvg = svgEl.cloneNode(true);
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
// 添加背景矩形(使用主题颜色)
|
||||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
bgRect.setAttribute('width', '100%');
|
||||
bgRect.setAttribute('height', '100%');
|
||||
bgRect.setAttribute('fill', colors.background);
|
||||
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
||||
|
||||
// 添加内联样式(使用主题颜色)
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = `
|
||||
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
|
||||
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
|
||||
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
|
||||
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
|
||||
strong {{ font-weight: 700; }}
|
||||
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
|
||||
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
|
||||
`;
|
||||
clonedSvg.insertBefore(style, bgRect.nextSibling);
|
||||
|
||||
// 将 foreignObject 转换为 text 以提高兼容性
|
||||
const foreignObjects = clonedSvg.querySelectorAll('foreignObject');
|
||||
foreignObjects.forEach(fo => {{
|
||||
const text = fo.textContent || '';
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
textEl.setAttribute('x', fo.getAttribute('x') || '0');
|
||||
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
|
||||
textEl.setAttribute('fill', colors.text);
|
||||
textEl.setAttribute('font-family', 'sans-serif');
|
||||
textEl.setAttribute('font-size', '14');
|
||||
textEl.textContent = text.trim();
|
||||
g.appendChild(textEl);
|
||||
fo.parentNode.replaceChild(g, fo);
|
||||
}});
|
||||
|
||||
// 序列化 SVG 为字符串
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
|
||||
// 清理容器
|
||||
document.body.removeChild(container);
|
||||
|
||||
// 将 SVG 字符串转换为 Blob
|
||||
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
|
||||
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
|
||||
|
||||
// 上传文件到 OpenWebUI API
|
||||
console.log("[思维导图图片] 正在上传 SVG 文件...");
|
||||
const token = localStorage.getItem("token");
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadResponse = await fetch('/api/v1/files/', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Authorization': `Bearer ${{token}}`
|
||||
}},
|
||||
body: formData
|
||||
}});
|
||||
|
||||
if (!uploadResponse.ok) {{
|
||||
throw new Error(`上传失败: ${{uploadResponse.statusText}}`);
|
||||
}}
|
||||
|
||||
const fileData = await uploadResponse.json();
|
||||
const fileId = fileData.id;
|
||||
const imageUrl = `/api/v1/files/${{fileId}}/content`;
|
||||
|
||||
console.log("[思维导图图片] 文件已上传, ID:", fileId);
|
||||
|
||||
// 生成包含文件 URL 的 markdown 图片
|
||||
const markdownImage = ``;
|
||||
|
||||
// 通过 API 更新消息
|
||||
if (chatId && messageId) {{
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// 带重试逻辑的请求函数
|
||||
const fetchWithRetry = async (url, options, retries = 3) => {{
|
||||
for (let i = 0; i < retries; i++) {{
|
||||
try {{
|
||||
const response = await fetch(url, options);
|
||||
if (response.ok) return response;
|
||||
if (i < retries - 1) {{
|
||||
console.log(`[思维导图图片] 重试 ${{i + 1}}/${{retries}}: ${{url}}`);
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||||
}}
|
||||
}} catch (e) {{
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||||
}}
|
||||
}}
|
||||
return null;
|
||||
}};
|
||||
|
||||
// 获取当前聊天数据
|
||||
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "GET",
|
||||
headers: {{ "Authorization": `Bearer ${{token}}` }}
|
||||
}});
|
||||
|
||||
if (!getResponse.ok) {{
|
||||
throw new Error("获取聊天数据失败: " + getResponse.status);
|
||||
}}
|
||||
|
||||
const chatData = await getResponse.json();
|
||||
let updatedMessages = [];
|
||||
let newContent = "";
|
||||
|
||||
if (chatData.chat && chatData.chat.messages) {{
|
||||
updatedMessages = chatData.chat.messages.map(m => {{
|
||||
if (m.id === messageId) {{
|
||||
const originalContent = m.content || "";
|
||||
// 移除已有的思维导图图片 (包括 base64 和文件 URL 格式)
|
||||
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
|
||||
let cleanedContent = originalContent.replace(mindmapPattern, "");
|
||||
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
||||
// 追加新图片
|
||||
newContent = cleanedContent + "\\n\\n" + markdownImage;
|
||||
|
||||
// 关键: 同时更新 messages 数组和 history 对象中的内容
|
||||
// history 对象是数据库的单一真值来源
|
||||
if (chatData.chat.history && chatData.chat.history.messages) {{
|
||||
if (chatData.chat.history.messages[messageId]) {{
|
||||
chatData.chat.history.messages[messageId].content = newContent;
|
||||
}}
|
||||
}}
|
||||
|
||||
return {{ ...m, content: newContent }};
|
||||
}}
|
||||
return m;
|
||||
}});
|
||||
}}
|
||||
|
||||
if (!newContent) {{
|
||||
console.warn("[思维导图图片] 找不到要更新的消息");
|
||||
return;
|
||||
}}
|
||||
|
||||
// 尝试通过事件 API 更新前端显示(可选,部分版本可能不支持)
|
||||
try {{
|
||||
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
type: "chat:message",
|
||||
data: {{ content: newContent }}
|
||||
}})
|
||||
}});
|
||||
}} catch (eventErr) {{
|
||||
// 事件 API 是可选的,继续执行持久化
|
||||
console.log("[思维导图图片] 事件 API 不可用,继续执行...");
|
||||
}}
|
||||
|
||||
// 通过更新整个聊天对象来持久化到数据库
|
||||
// 遵循 OpenWebUI 后端控制的 API 流程
|
||||
const updatePayload = {{
|
||||
chat: {{
|
||||
...chatData.chat,
|
||||
messages: updatedMessages
|
||||
// history 已在上面原地更新
|
||||
}}
|
||||
}};
|
||||
|
||||
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
|
||||
method: "POST",
|
||||
headers: {{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${{token}}`
|
||||
}},
|
||||
body: JSON.stringify(updatePayload)
|
||||
}});
|
||||
|
||||
if (persistResponse && persistResponse.ok) {{
|
||||
console.log("[思维导图图片] ✅ 消息已持久化保存!");
|
||||
}} else {{
|
||||
console.error("[思维导图图片] ❌ 重试后仍然无法持久化消息");
|
||||
}}
|
||||
}} else {{
|
||||
console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId,无法持久化");
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
console.error("[思维导图图片] 错误:", error);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
|
||||
async def action(
|
||||
self,
|
||||
body: dict,
|
||||
__user__: Optional[Dict[str, Any]] = None,
|
||||
__event_emitter__: Optional[Any] = None,
|
||||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||||
__metadata__: Optional[dict] = None,
|
||||
__request__: Optional[Request] = None,
|
||||
) -> Optional[dict]:
|
||||
logger.info("Action: 思维导图 (v12 - Final Feedback Fix) started")
|
||||
logger.info("Action: 思维导图 (v0.9.1) started")
|
||||
user_ctx = self._get_user_context(__user__)
|
||||
user_language = user_ctx["user_language"]
|
||||
user_name = user_ctx["user_name"]
|
||||
@@ -923,7 +1343,7 @@ class Action:
|
||||
current_year = now_dt.strftime("%Y")
|
||||
current_timezone_str = tz_env or "UTC"
|
||||
except Exception as e:
|
||||
logger.warning(f"获取时区信息失败: {e},使用默认值。")
|
||||
logger.warning(f"获取时区信息失败: {e},使用默认值。")
|
||||
now = datetime.now()
|
||||
current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M:%S")
|
||||
current_weekday_zh = "未知星期"
|
||||
@@ -931,7 +1351,7 @@ class Action:
|
||||
current_timezone_str = "未知时区"
|
||||
|
||||
await self._emit_notification(
|
||||
__event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info"
|
||||
__event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info"
|
||||
)
|
||||
|
||||
messages = body.get("messages")
|
||||
@@ -957,7 +1377,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "助手" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} 消息 {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
error_message = "无法获取有效的用户消息内容。"
|
||||
@@ -980,7 +1400,7 @@ class Action:
|
||||
long_text_content = original_content.strip()
|
||||
|
||||
if len(long_text_content) < self.valves.MIN_TEXT_LENGTH:
|
||||
short_text_message = f"文本内容过短({len(long_text_content)}字符),无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。"
|
||||
short_text_message = f"文本内容过短({len(long_text_content)}字符),无法进行有效分析。请提供至少{self.valves.MIN_TEXT_LENGTH}字符的文本。"
|
||||
await self._emit_notification(
|
||||
__event_emitter__, short_text_message, "warning"
|
||||
)
|
||||
@@ -1021,7 +1441,7 @@ class Action:
|
||||
}
|
||||
user_obj = Users.get_user_by_id(user_id)
|
||||
if not user_obj:
|
||||
raise ValueError(f"无法获取用户对象,用户ID: {user_id}")
|
||||
raise ValueError(f"无法获取用户对象,用户ID: {user_id}")
|
||||
|
||||
llm_response = await generate_chat_completion(
|
||||
__request__, llm_payload, user_obj
|
||||
@@ -1084,26 +1504,65 @@ class Action:
|
||||
user_language,
|
||||
)
|
||||
|
||||
# 检查输出模式
|
||||
if self.valves.OUTPUT_MODE == "image":
|
||||
# 图片模式: 使用 JavaScript 渲染并嵌入为 Markdown 图片
|
||||
chat_id = self._extract_chat_id(body, __metadata__)
|
||||
message_id = self._extract_message_id(body, __metadata__)
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__,
|
||||
"思维导图: 正在渲染图片...",
|
||||
False,
|
||||
)
|
||||
|
||||
if __event_call__:
|
||||
js_code = self._generate_image_js_code(
|
||||
unique_id=unique_id,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
markdown_syntax=markdown_syntax,
|
||||
)
|
||||
|
||||
await __event_call__(
|
||||
{
|
||||
"type": "execute",
|
||||
"data": {"code": js_code},
|
||||
}
|
||||
)
|
||||
|
||||
await self._emit_status(
|
||||
__event_emitter__, "思维导图: 图片已生成!", True
|
||||
)
|
||||
await self._emit_notification(
|
||||
__event_emitter__,
|
||||
f"思维导图图片已生成,{user_name}!",
|
||||
"success",
|
||||
)
|
||||
logger.info("Action: 思维导图 (v0.9.1) 图片模式完成")
|
||||
return body
|
||||
|
||||
# HTML 模式(默认): 嵌入为 HTML 块
|
||||
html_embed_tag = f"```html\n{final_html}\n```"
|
||||
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
||||
|
||||
await self._emit_status(__event_emitter__, "思维导图: 绘制完成!", True)
|
||||
await self._emit_status(__event_emitter__, "思维导图: 绘制完成!", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, f"思维导图已生成,{user_name}!", "success"
|
||||
__event_emitter__, f"思维导图已生成,{user_name}!", "success"
|
||||
)
|
||||
logger.info("Action: 思维导图 (v12) completed successfully")
|
||||
logger.info("Action: 思维导图 (v0.9.1) HTML 模式完成")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"思维导图处理失败: {str(e)}"
|
||||
logger.error(f"思维导图错误: {error_message}", exc_info=True)
|
||||
user_facing_error = f"抱歉,思维导图在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
user_facing_error = f"抱歉,思维导图在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
||||
body["messages"][-1][
|
||||
"content"
|
||||
] = f"{long_text_content}\n\n❌ **错误:** {user_facing_error}"
|
||||
|
||||
await self._emit_status(__event_emitter__, "思维导图: 处理失败。", True)
|
||||
await self._emit_notification(
|
||||
__event_emitter__, f"思维导图生成失败, {user_name}!", "error"
|
||||
__event_emitter__, f"思维导图生成失败, {user_name}!", "error"
|
||||
)
|
||||
|
||||
return body
|
||||
@@ -22,3 +22,9 @@ GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.1.2
|
||||
|
||||
- Removed debug messages from output
|
||||
|
||||
@@ -22,3 +22,9 @@ GitHub: [Fu-Jie/awesome-openwebui](https://github.com/Fu-Jie/awesome-openwebui)
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v0.1.2
|
||||
|
||||
- 移除输出中的调试信息
|
||||
|
||||
@@ -3,7 +3,7 @@ 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.0
|
||||
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
|
||||
@@ -529,9 +529,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "Assistant" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(
|
||||
f"[{role_label} Message {i}]\n{text_content}"
|
||||
)
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("Unable to get valid user message content.")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
title: 精读 (Deep Reading)
|
||||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNDI4NWY0Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWU4OGU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSJ1cmwoI2cpIi8+PHBhdGggZD0iTTE0IDJsNiA2aC02eiIgZmlsbD0iIzFlODhlNSIgb3BhY2l0eT0iMC42Ii8+PGxpbmUgeDE9IjgiIHkxPSIxMyIgeDI9IjE2IiB5Mj0iMTMiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48bGluZSB4MT0iOCIgeTE9IjE3IiB4Mj0iMTQiIHkyPSIxNyIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiNmZmQ3MDAiLz48cGF0aCBkPSJNMTYgMTZsMS41IDEuNSIgc3Ryb2tlPSIjNDI4NWY0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==
|
||||
version: 0.1.0
|
||||
version: 0.1.2
|
||||
description: 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议,适合工作和学习场景。
|
||||
requirements: jinja2, markdown
|
||||
"""
|
||||
@@ -528,7 +528,7 @@ class Action:
|
||||
if role == "user"
|
||||
else "助手" if role == "assistant" else role
|
||||
)
|
||||
aggregated_parts.append(f"[{role_label} 消息 {i}]\n{text_content}")
|
||||
aggregated_parts.append(f"{text_content}")
|
||||
|
||||
if not aggregated_parts:
|
||||
raise ValueError("无法获取有效的用户消息内容。")
|
||||
@@ -41,19 +41,19 @@
|
||||
|
||||
## Actions (动作插件)
|
||||
|
||||
1. **📊 智能信息图 (infographic/信息图.py)** - 基于 AntV Infographic 的智能信息图生成插件,支持多种专业模板与 SVG/PNG 下载
|
||||
1. **📊 智能信息图 (infographic/infographic_cn.py)** - 基于 AntV Infographic 的智能信息图生成插件,支持多种专业模板与 SVG/PNG 下载
|
||||
|
||||
2. **🧠 思维导图 (smart-mind-map/思维导图.py)** - 智能分析文本内容生成交互式思维导图,帮助用户结构化和可视化知识
|
||||
2. **🧠 思维导图 (smart-mind-map/smart_mind_map_cn.py)** - 智能分析文本内容生成交互式思维导图,帮助用户结构化和可视化知识
|
||||
|
||||
3. **📊 导出为 Excel (export_to_excel/导出为Excel.py)** - 将对话历史中的 Markdown 表格导出为符合中国规范的 Excel 文件
|
||||
3. **📊 导出为 Excel (export_to_excel/export_to_excel_cn.py)** - 将对话历史中的 Markdown 表格导出为符合中国规范的 Excel 文件
|
||||
|
||||
4. **⚡ 闪记卡 (knowledge-card/闪记卡.py)** - 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类
|
||||
4. **⚡ 闪记卡 (flash-card/flash_card_cn.py)** - 快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类
|
||||
|
||||
5. **📖 精读 (summary/精读.py)** - 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议
|
||||
5. **📖 精读 (summary/summary_cn.py)** - 深度分析长篇文本,提炼详细摘要、关键信息点和可执行的行动建议
|
||||
|
||||
## Filters (过滤器插件)
|
||||
|
||||
1. **🔄 异步上下文压缩 (async-context-compression/异步上下文压缩.py)** - 异步生成摘要并压缩对话历史,支持数据库持久化存储
|
||||
1. **🔄 异步上下文压缩 (async-context-compression/async_context_compression_cn.py)** - 异步生成摘要并压缩对话历史,支持数据库持久化存储
|
||||
|
||||
2. **✨ 上下文增强过滤器 (context_enhancement_filter/context_enhancement_filter.py)** - 增强请求上下文和优化模型功能,包含环境变量管理、模型功能适配和内容清洗
|
||||
|
||||
|
||||
550
scripts/openwebui_stats.py
Normal file
550
scripts/openwebui_stats.py
Normal file
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenWebUI 社区统计工具
|
||||
|
||||
获取并统计你在 openwebui.com 上发布的插件/帖子数据。
|
||||
|
||||
使用方法:
|
||||
1. 设置环境变量:
|
||||
- OPENWEBUI_API_KEY: 你的 API Key
|
||||
- OPENWEBUI_USER_ID: 你的用户 ID
|
||||
2. 运行: python scripts/openwebui_stats.py
|
||||
|
||||
获取 API Key:
|
||||
访问 https://openwebui.com/settings/api 创建 API Key (sk-开头)
|
||||
|
||||
获取 User ID:
|
||||
从个人主页的 API 请求中获取,格式如: b15d1348-4347-42b4-b815-e053342d6cb0
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
# 北京时区 (UTC+8)
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def get_beijing_time() -> datetime:
|
||||
"""获取当前北京时间"""
|
||||
return datetime.now(BEIJING_TZ)
|
||||
|
||||
|
||||
# 尝试加载 .env 文件
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class OpenWebUIStats:
|
||||
"""OpenWebUI 社区统计工具"""
|
||||
|
||||
BASE_URL = "https://api.openwebui.com/api/v1"
|
||||
|
||||
def __init__(self, api_key: str, user_id: Optional[str] = None):
|
||||
"""
|
||||
初始化统计工具
|
||||
|
||||
Args:
|
||||
api_key: OpenWebUI API Key (JWT Token)
|
||||
user_id: 用户 ID,如果为 None 则从 token 中解析
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.user_id = user_id or self._parse_user_id_from_token(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _parse_user_id_from_token(self, token: str) -> str:
|
||||
"""从 JWT Token 中解析用户 ID"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
# JWT 格式: header.payload.signature
|
||||
payload = token.split(".")[1]
|
||||
# 添加 padding
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
data = json.loads(decoded)
|
||||
return data.get("id", "")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 无法从 Token 解析用户 ID: {e}")
|
||||
return ""
|
||||
|
||||
def get_user_posts(self, sort: str = "new", page: int = 1) -> list:
|
||||
"""
|
||||
获取用户发布的帖子列表
|
||||
|
||||
Args:
|
||||
sort: 排序方式 (new/top/hot)
|
||||
page: 页码
|
||||
|
||||
Returns:
|
||||
帖子列表
|
||||
"""
|
||||
url = f"{self.BASE_URL}/posts/users/{self.user_id}"
|
||||
params = {"sort": sort, "page": page}
|
||||
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_all_posts(self, sort: str = "new") -> list:
|
||||
"""获取所有帖子(自动分页)"""
|
||||
all_posts = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
posts = self.get_user_posts(sort=sort, page=page)
|
||||
if not posts:
|
||||
break
|
||||
all_posts.extend(posts)
|
||||
page += 1
|
||||
|
||||
return all_posts
|
||||
|
||||
def generate_stats(self, posts: list) -> dict:
|
||||
"""生成统计数据"""
|
||||
stats = {
|
||||
"total_posts": len(posts),
|
||||
"total_downloads": 0,
|
||||
"total_views": 0,
|
||||
"total_upvotes": 0,
|
||||
"total_downvotes": 0,
|
||||
"total_saves": 0,
|
||||
"total_comments": 0,
|
||||
"by_type": {},
|
||||
"posts": [],
|
||||
"user": {}, # 用户信息
|
||||
}
|
||||
|
||||
# 从第一个帖子中提取用户信息
|
||||
if posts and "user" in posts[0]:
|
||||
user = posts[0]["user"]
|
||||
stats["user"] = {
|
||||
"username": user.get("username", ""),
|
||||
"name": user.get("name", ""),
|
||||
"profile_url": f"https://openwebui.com/u/{user.get('username', '')}",
|
||||
"profile_image": user.get("profileImageUrl", ""),
|
||||
"followers": user.get("followerCount", 0),
|
||||
"following": user.get("followingCount", 0),
|
||||
"total_points": user.get("totalPoints", 0),
|
||||
"post_points": user.get("postPoints", 0),
|
||||
"comment_points": user.get("commentPoints", 0),
|
||||
"contributions": user.get("totalContributions", 0),
|
||||
}
|
||||
|
||||
for post in posts:
|
||||
# 累计统计
|
||||
stats["total_downloads"] += post.get("downloads", 0)
|
||||
stats["total_views"] += post.get("views", 0)
|
||||
stats["total_upvotes"] += post.get("upvotes", 0)
|
||||
stats["total_downvotes"] += post.get("downvotes", 0)
|
||||
stats["total_saves"] += post.get("saveCount", 0)
|
||||
stats["total_comments"] += post.get("commentCount", 0)
|
||||
|
||||
# 解析 data 字段 - 正确路径: data.function.meta
|
||||
function_data = post.get("data", {}).get("function", {})
|
||||
meta = function_data.get("meta", {})
|
||||
manifest = meta.get("manifest", {})
|
||||
post_type = meta.get("type", function_data.get("type", "unknown"))
|
||||
|
||||
if post_type not in stats["by_type"]:
|
||||
stats["by_type"][post_type] = 0
|
||||
stats["by_type"][post_type] += 1
|
||||
|
||||
# 单个帖子信息
|
||||
created_at = datetime.fromtimestamp(post.get("createdAt", 0))
|
||||
updated_at = datetime.fromtimestamp(post.get("updatedAt", 0))
|
||||
|
||||
stats["posts"].append(
|
||||
{
|
||||
"title": post.get("title", ""),
|
||||
"slug": post.get("slug", ""),
|
||||
"type": post_type,
|
||||
"version": manifest.get("version", ""),
|
||||
"author": manifest.get("author", ""),
|
||||
"description": meta.get("description", ""),
|
||||
"downloads": post.get("downloads", 0),
|
||||
"views": post.get("views", 0),
|
||||
"upvotes": post.get("upvotes", 0),
|
||||
"saves": post.get("saveCount", 0),
|
||||
"comments": post.get("commentCount", 0),
|
||||
"created_at": created_at.strftime("%Y-%m-%d"),
|
||||
"updated_at": updated_at.strftime("%Y-%m-%d"),
|
||||
"url": f"https://openwebui.com/posts/{post.get('slug', '')}",
|
||||
}
|
||||
)
|
||||
|
||||
# 按下载量排序
|
||||
stats["posts"].sort(key=lambda x: x["downloads"], reverse=True)
|
||||
|
||||
return stats
|
||||
|
||||
def print_stats(self, stats: dict):
|
||||
"""打印统计报告到终端"""
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 OpenWebUI 社区统计报告")
|
||||
print("=" * 60)
|
||||
print(f"📅 生成时间 (北京): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}")
|
||||
print()
|
||||
|
||||
# 总览
|
||||
print("📈 总览")
|
||||
print("-" * 40)
|
||||
print(f" 📝 发布数量: {stats['total_posts']}")
|
||||
print(f" ⬇️ 总下载量: {stats['total_downloads']}")
|
||||
print(f" 👁️ 总浏览量: {stats['total_views']}")
|
||||
print(f" 👍 总点赞数: {stats['total_upvotes']}")
|
||||
print(f" 💾 总收藏数: {stats['total_saves']}")
|
||||
print(f" 💬 总评论数: {stats['total_comments']}")
|
||||
print()
|
||||
|
||||
# 按类型分类
|
||||
print("📂 按类型分类")
|
||||
print("-" * 40)
|
||||
for post_type, count in stats["by_type"].items():
|
||||
print(f" • {post_type}: {count}")
|
||||
print()
|
||||
|
||||
# 详细列表
|
||||
print("📋 发布列表 (按下载量排序)")
|
||||
print("-" * 60)
|
||||
|
||||
# 表头
|
||||
print(f"{'排名':<4} {'标题':<30} {'下载':<8} {'浏览':<8} {'点赞':<6}")
|
||||
print("-" * 60)
|
||||
|
||||
for i, post in enumerate(stats["posts"], 1):
|
||||
title = (
|
||||
post["title"][:28] + ".." if len(post["title"]) > 30 else post["title"]
|
||||
)
|
||||
print(
|
||||
f"{i:<4} {title:<30} {post['downloads']:<8} {post['views']:<8} {post['upvotes']:<6}"
|
||||
)
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
def generate_markdown(self, stats: dict, lang: str = "zh") -> str:
|
||||
"""
|
||||
生成 Markdown 格式报告
|
||||
|
||||
Args:
|
||||
stats: 统计数据
|
||||
lang: 语言 ("zh" 中文, "en" 英文)
|
||||
"""
|
||||
# 中英文文本
|
||||
texts = {
|
||||
"zh": {
|
||||
"title": "# 📊 OpenWebUI 社区统计报告",
|
||||
"updated": f"> 📅 更新时间: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
|
||||
"overview_title": "## 📈 总览",
|
||||
"overview_header": "| 指标 | 数值 |",
|
||||
"posts": "📝 发布数量",
|
||||
"downloads": "⬇️ 总下载量",
|
||||
"views": "👁️ 总浏览量",
|
||||
"upvotes": "👍 总点赞数",
|
||||
"saves": "💾 总收藏数",
|
||||
"comments": "💬 总评论数",
|
||||
"type_title": "## 📂 按类型分类",
|
||||
"list_title": "## 📋 发布列表",
|
||||
"list_header": "| 排名 | 标题 | 类型 | 版本 | 下载 | 浏览 | 点赞 | 收藏 | 更新日期 |",
|
||||
},
|
||||
"en": {
|
||||
"title": "# 📊 OpenWebUI Community Stats Report",
|
||||
"updated": f"> 📅 Updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
|
||||
"overview_title": "## 📈 Overview",
|
||||
"overview_header": "| Metric | Value |",
|
||||
"posts": "📝 Total Posts",
|
||||
"downloads": "⬇️ Total Downloads",
|
||||
"views": "👁️ Total Views",
|
||||
"upvotes": "👍 Total Upvotes",
|
||||
"saves": "💾 Total Saves",
|
||||
"comments": "💬 Total Comments",
|
||||
"type_title": "## 📂 By Type",
|
||||
"list_title": "## 📋 Posts List",
|
||||
"list_header": "| Rank | Title | Type | Version | Downloads | Views | Upvotes | Saves | Updated |",
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(lang, texts["en"])
|
||||
|
||||
md = []
|
||||
md.append(t["title"])
|
||||
md.append("")
|
||||
md.append(t["updated"])
|
||||
md.append("")
|
||||
|
||||
# 总览
|
||||
md.append(t["overview_title"])
|
||||
md.append("")
|
||||
md.append(t["overview_header"])
|
||||
md.append("|------|------|")
|
||||
md.append(f"| {t['posts']} | {stats['total_posts']} |")
|
||||
md.append(f"| {t['downloads']} | {stats['total_downloads']} |")
|
||||
md.append(f"| {t['views']} | {stats['total_views']} |")
|
||||
md.append(f"| {t['upvotes']} | {stats['total_upvotes']} |")
|
||||
md.append(f"| {t['saves']} | {stats['total_saves']} |")
|
||||
md.append(f"| {t['comments']} | {stats['total_comments']} |")
|
||||
md.append("")
|
||||
|
||||
# 按类型分类
|
||||
md.append(t["type_title"])
|
||||
md.append("")
|
||||
for post_type, count in stats["by_type"].items():
|
||||
md.append(f"- **{post_type}**: {count}")
|
||||
md.append("")
|
||||
|
||||
# 详细列表
|
||||
md.append(t["list_title"])
|
||||
md.append("")
|
||||
md.append(t["list_header"])
|
||||
md.append("|:---:|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|")
|
||||
|
||||
for i, post in enumerate(stats["posts"], 1):
|
||||
title_link = f"[{post['title']}]({post['url']})"
|
||||
md.append(
|
||||
f"| {i} | {title_link} | {post['type']} | {post['version']} | "
|
||||
f"{post['downloads']} | {post['views']} | {post['upvotes']} | "
|
||||
f"{post['saves']} | {post['updated_at']} |"
|
||||
)
|
||||
|
||||
md.append("")
|
||||
return "\n".join(md)
|
||||
|
||||
def save_json(self, stats: dict, filepath: str):
|
||||
"""保存 JSON 格式数据"""
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(stats, f, ensure_ascii=False, indent=2)
|
||||
print(f"✅ JSON 数据已保存到: {filepath}")
|
||||
|
||||
def generate_readme_stats(self, stats: dict, lang: str = "zh") -> str:
|
||||
"""
|
||||
生成 README 统计徽章区域
|
||||
|
||||
Args:
|
||||
stats: 统计数据
|
||||
lang: 语言 ("zh" 中文, "en" 英文)
|
||||
"""
|
||||
# 获取 Top 5 插件
|
||||
top_plugins = stats["posts"][:5]
|
||||
|
||||
# 中英文文本
|
||||
texts = {
|
||||
"zh": {
|
||||
"title": "## 📊 社区统计",
|
||||
"updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
|
||||
"author_header": "| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |",
|
||||
"header": "| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |",
|
||||
"top5_title": "### 🔥 热门插件 Top 5",
|
||||
"top5_header": "| 排名 | 插件 | 下载 | 浏览 |",
|
||||
"full_stats": "*完整统计请查看 [社区统计报告](./docs/community-stats.md)*",
|
||||
},
|
||||
"en": {
|
||||
"title": "## 📊 Community Stats",
|
||||
"updated": f"> 🕐 Auto-updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')}",
|
||||
"author_header": "| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |",
|
||||
"header": "| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |",
|
||||
"top5_title": "### 🔥 Top 5 Popular Plugins",
|
||||
"top5_header": "| Rank | Plugin | Downloads | Views |",
|
||||
"full_stats": "*See full stats in [Community Stats Report](./docs/community-stats.md)*",
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(lang, texts["en"])
|
||||
user = stats.get("user", {})
|
||||
|
||||
lines = []
|
||||
lines.append("<!-- STATS_START -->")
|
||||
lines.append(t["title"])
|
||||
lines.append("")
|
||||
lines.append(t["updated"])
|
||||
lines.append("")
|
||||
|
||||
# 作者信息表格
|
||||
if user:
|
||||
username = user.get("username", "")
|
||||
profile_url = user.get("profile_url", "")
|
||||
lines.append(t["author_header"])
|
||||
lines.append("|:---:|:---:|:---:|:---:|")
|
||||
lines.append(
|
||||
f"| [{username}]({profile_url}) | **{user.get('followers', 0)}** | "
|
||||
f"**{user.get('total_points', 0)}** | **{user.get('contributions', 0)}** |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# 统计徽章表格
|
||||
lines.append(t["header"])
|
||||
lines.append("|:---:|:---:|:---:|:---:|:---:|")
|
||||
lines.append(
|
||||
f"| **{stats['total_posts']}** | **{stats['total_downloads']}** | "
|
||||
f"**{stats['total_views']}** | **{stats['total_upvotes']}** | **{stats['total_saves']}** |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Top 5 热门插件
|
||||
lines.append(t["top5_title"])
|
||||
lines.append("")
|
||||
lines.append(t["top5_header"])
|
||||
lines.append("|:---:|------|:---:|:---:|")
|
||||
|
||||
medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"]
|
||||
for i, post in enumerate(top_plugins):
|
||||
medal = medals[i] if i < len(medals) else str(i + 1)
|
||||
lines.append(
|
||||
f"| {medal} | [{post['title']}]({post['url']}) | {post['downloads']} | {post['views']} |"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(t["full_stats"])
|
||||
lines.append("<!-- STATS_END -->")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def update_readme(self, stats: dict, readme_path: str, lang: str = "zh"):
|
||||
"""
|
||||
更新 README 文件中的统计区域
|
||||
|
||||
Args:
|
||||
stats: 统计数据
|
||||
readme_path: README 文件路径
|
||||
lang: 语言 ("zh" 中文, "en" 英文)
|
||||
"""
|
||||
import re
|
||||
|
||||
# 读取现有内容
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# 生成新的统计区域
|
||||
new_stats = self.generate_readme_stats(stats, lang)
|
||||
|
||||
# 检查是否已有统计区域
|
||||
pattern = r"<!-- STATS_START -->.*?<!-- STATS_END -->"
|
||||
if re.search(pattern, content, re.DOTALL):
|
||||
# 替换现有区域
|
||||
new_content = re.sub(pattern, new_stats, content, flags=re.DOTALL)
|
||||
else:
|
||||
# 在简介段落之后插入统计区域
|
||||
# 查找模式:标题 -> 语言切换行 -> 简介段落 -> 插入位置
|
||||
lines = content.split("\n")
|
||||
insert_pos = 0
|
||||
found_intro = False
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# 跳过标题
|
||||
if line.startswith("# "):
|
||||
continue
|
||||
# 跳过空行
|
||||
if line.strip() == "":
|
||||
continue
|
||||
# 跳过语言切换行 (如 "English | [中文]" 或 "[English] | 中文")
|
||||
if ("English" in line or "中文" in line) and "|" in line:
|
||||
continue
|
||||
# 找到第一个非空、非标题、非语言切换的段落(简介)
|
||||
if not found_intro:
|
||||
found_intro = True
|
||||
# 继续到这个段落结束
|
||||
continue
|
||||
# 简介段落后的空行或下一个标题就是插入位置
|
||||
if line.strip() == "" or line.startswith("#"):
|
||||
insert_pos = i
|
||||
break
|
||||
|
||||
# 如果没找到合适位置,就放在第3行(标题和语言切换后)
|
||||
if insert_pos == 0:
|
||||
insert_pos = 3
|
||||
|
||||
# 在适当位置插入
|
||||
lines.insert(insert_pos, "")
|
||||
lines.insert(insert_pos + 1, new_stats)
|
||||
lines.insert(insert_pos + 2, "")
|
||||
new_content = "\n".join(lines)
|
||||
|
||||
# 写回文件
|
||||
with open(readme_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"✅ README 已更新: {readme_path}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 获取配置
|
||||
api_key = os.getenv("OPENWEBUI_API_KEY")
|
||||
user_id = os.getenv("OPENWEBUI_USER_ID")
|
||||
|
||||
if not api_key:
|
||||
print("❌ 错误: 未设置 OPENWEBUI_API_KEY 环境变量")
|
||||
print("请设置环境变量:")
|
||||
print(" export OPENWEBUI_API_KEY='your_api_key_here'")
|
||||
return 1
|
||||
|
||||
if not user_id:
|
||||
print("❌ 错误: 未设置 OPENWEBUI_USER_ID 环境变量")
|
||||
print("请设置环境变量:")
|
||||
print(" export OPENWEBUI_USER_ID='your_user_id_here'")
|
||||
print("\n提示: 用户 ID 可以从之前的 curl 请求中获取")
|
||||
print(" 例如: b15d1348-4347-42b4-b815-e053342d6cb0")
|
||||
return 1
|
||||
|
||||
# 初始化
|
||||
stats_client = OpenWebUIStats(api_key, user_id)
|
||||
print(f"🔍 用户 ID: {stats_client.user_id}")
|
||||
|
||||
# 获取所有帖子
|
||||
print("📥 正在获取帖子数据...")
|
||||
posts = stats_client.get_all_posts()
|
||||
print(f"✅ 获取到 {len(posts)} 个帖子")
|
||||
|
||||
# 生成统计
|
||||
stats = stats_client.generate_stats(posts)
|
||||
|
||||
# 打印到终端
|
||||
stats_client.print_stats(stats)
|
||||
|
||||
# 保存 Markdown 报告 (中英文双版本)
|
||||
script_dir = Path(__file__).parent.parent
|
||||
|
||||
# 中文报告
|
||||
md_zh_path = script_dir / "docs" / "community-stats.md"
|
||||
md_zh_content = stats_client.generate_markdown(stats, lang="zh")
|
||||
with open(md_zh_path, "w", encoding="utf-8") as f:
|
||||
f.write(md_zh_content)
|
||||
print(f"\n✅ 中文报告已保存到: {md_zh_path}")
|
||||
|
||||
# 英文报告
|
||||
md_en_path = script_dir / "docs" / "community-stats.en.md"
|
||||
md_en_content = stats_client.generate_markdown(stats, lang="en")
|
||||
with open(md_en_path, "w", encoding="utf-8") as f:
|
||||
f.write(md_en_content)
|
||||
print(f"✅ 英文报告已保存到: {md_en_path}")
|
||||
|
||||
# 保存 JSON 数据
|
||||
json_path = script_dir / "docs" / "community-stats.json"
|
||||
stats_client.save_json(stats, str(json_path))
|
||||
|
||||
# 更新 README 文件
|
||||
readme_path = script_dir / "README.md"
|
||||
readme_cn_path = script_dir / "README_CN.md"
|
||||
stats_client.update_readme(stats, str(readme_path), lang="en")
|
||||
stats_client.update_readme(stats, str(readme_cn_path), lang="zh")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user