diff --git a/.agent/workflows/plugin-development.md b/.agent/workflows/plugin-development.md index 84106ea..ce3a786 100644 --- a/.agent/workflows/plugin-development.md +++ b/.agent/workflows/plugin-development.md @@ -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. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0fc736c..0990bb2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -949,7 +949,7 @@ async def action(self, body, __event_call__, __metadata__, ...): #### 优势 - **纯 Markdown 输出**:结果是标准的 Markdown 图片语法,无需 HTML 代码块 -- **自包含**:图片以 Base64 Data URL 嵌入,无外部依赖 +- **高效存储**:图片上传至 `/api/v1/files`,避免 Base64 字符串膨胀聊天记录 - **持久化**:通过 API 回写,消息重新加载后图片仍然存在 - **跨平台**:任何支持 Markdown 图片的客户端都能显示 - **无服务端渲染依赖**:利用用户浏览器的渲染能力 @@ -960,7 +960,7 @@ async def action(self, body, __event_call__, __metadata__, ...): |------|-------------------------|------------------------| | 输出格式 | HTML 代码块 | Markdown 图片 | | 交互性 | ✅ 支持按钮、动画 | ❌ 静态图片 | -| 外部依赖 | 需要加载 JS 库 | 无(图片自包含) | +| 外部依赖 | 需要加载 JS 库 | 依赖 `/api/v1/files` 存储 | | 持久化 | 依赖浏览器渲染 | ✅ 永久可见 | | 文件导出 | 需特殊处理 | ✅ 直接导出 | | 适用场景 | 交互式内容 | 信息图、图表快照 | @@ -970,7 +970,199 @@ async def action(self, body, __event_call__, __metadata__, ...): - `plugins/actions/js-render-poc/infographic_markdown.py` - AntV Infographic 生成并嵌入 - `plugins/actions/js-render-poc/js_render_poc.py` - 基础概念验证 +### OpenWebUI Chat API 更新规范 (Chat API Update Specification) +当插件需要修改消息内容并持久化到数据库时,必须遵循 OpenWebUI 的 Backend-Controlled API 流程。 + +When a plugin needs to modify message content and persist it to the database, follow OpenWebUI's Backend-Controlled API flow. + +#### 核心概念 (Core Concepts) + +1. **Event API** (`/api/v1/chats/{chatId}/messages/{messageId}/event`) + - 用于**即时更新前端显示**,用户无需刷新页面 + - 是可选的,部分版本可能不支持 + - 仅影响当前会话的 UI,不持久化 + +2. **Chat Persistence API** (`/api/v1/chats/{chatId}`) + - 用于**持久化到数据库**,确保刷新页面后数据仍存在 + - 必须同时更新 `messages[]` 数组和 `history.messages` 对象 + - 是消息持久化的唯一可靠方式 + +#### 数据结构 (Data Structure) + +OpenWebUI 的 Chat 对象包含两个关键位置存储消息内容: + +```javascript +{ + "chat": { + "id": "chat-uuid", + "title": "Chat Title", + "messages": [ // 1️⃣ 消息数组 + { "id": "msg-1", "role": "user", "content": "..." }, + { "id": "msg-2", "role": "assistant", "content": "..." } + ], + "history": { + "current_id": "msg-2", + "messages": { // 2️⃣ 消息索引对象 + "msg-1": { "id": "msg-1", "role": "user", "content": "..." }, + "msg-2": { "id": "msg-2", "role": "assistant", "content": "..." } + } + } + } +} +``` + +> **重要**:修改消息时,**必须同时更新两个位置**,否则可能导致数据不一致。 + +#### 标准实现流程 (Standard Implementation) + +```javascript +(async function() { + const chatId = "{chat_id}"; + const messageId = "{message_id}"; + const token = localStorage.getItem("token"); + + // 1️⃣ 获取当前 Chat 数据 + const getResponse = await fetch(`/api/v1/chats/${chatId}`, { + method: "GET", + headers: { "Authorization": `Bearer ${token}` } + }); + const chatData = await getResponse.json(); + + // 2️⃣ 使用 map 遍历 messages,只修改目标消息 + let newContent = ""; + const updatedMessages = chatData.chat.messages.map(m => { + if (m.id === messageId) { + const originalContent = m.content || ""; + newContent = originalContent + "\n\n" + newMarkdown; + + // 3️⃣ 同时更新 history.messages 中对应的消息 + if (chatData.chat.history && chatData.chat.history.messages) { + if (chatData.chat.history.messages[messageId]) { + chatData.chat.history.messages[messageId].content = newContent; + } + } + + // 4️⃣ 保留消息的其他属性,只修改 content + return { ...m, content: newContent }; + } + return m; // 其他消息原样返回 + }); + + // 5️⃣ 通过 Event API 即时更新前端(可选) + try { + await fetch(`/api/v1/chats/${chatId}/messages/${messageId}/event`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify({ + type: "chat:message", + data: { content: newContent } + }) + }); + } catch (e) { + // Event API 是可选的,继续执行持久化 + console.log("Event API not available, continuing..."); + } + + // 6️⃣ 持久化到数据库(必须) + const updatePayload = { + chat: { + ...chatData.chat, // 保留所有原有属性 + messages: updatedMessages + // history 已在上面原地修改 + } + }; + + await fetch(`/api/v1/chats/${chatId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(updatePayload) + }); +})(); +``` + +#### 最佳实践 (Best Practices) + +1. **保留原有结构**:使用展开运算符 `...chatData.chat` 和 `...m` 确保不丢失任何原有属性 +2. **双位置更新**:必须同时更新 `messages[]` 和 `history.messages[id]` +3. **错误处理**:Event API 调用应包裹在 try-catch 中,失败时继续持久化 +4. **重试机制**:对持久化 API 实现重试逻辑,提高可靠性 + +```javascript +// 带重试的请求函数 +const fetchWithRetry = async (url, options, retries = 3) => { + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(url, options); + if (response.ok) return response; + if (i < retries - 1) { + await new Promise(r => setTimeout(r, 1000 * (i + 1))); // 指数退避 + } + } catch (e) { + if (i === retries - 1) throw e; + await new Promise(r => setTimeout(r, 1000 * (i + 1))); + } + } + return null; +}; +``` + +5. **禁止使用的 API**:不要使用 `/api/v1/chats/{chatId}/share` 作为持久化备用方案,该 API 用于分享功能,不是更新功能 + +#### 提取 Chat ID 和 Message ID (Extracting IDs) + +```python +def _extract_chat_id(self, body: dict, metadata: Optional[dict]) -> str: + """从 body 或 metadata 中提取 chat_id""" + if isinstance(body, dict): + chat_id = body.get("chat_id") + if isinstance(chat_id, str) and chat_id.strip(): + return chat_id.strip() + + body_metadata = body.get("metadata", {}) + if isinstance(body_metadata, dict): + chat_id = body_metadata.get("chat_id") + if isinstance(chat_id, str) and chat_id.strip(): + return chat_id.strip() + + if isinstance(metadata, dict): + chat_id = metadata.get("chat_id") + if isinstance(chat_id, str) and chat_id.strip(): + return chat_id.strip() + + return "" + +def _extract_message_id(self, body: dict, metadata: Optional[dict]) -> str: + """从 body 或 metadata 中提取 message_id""" + if isinstance(body, dict): + message_id = body.get("id") + if isinstance(message_id, str) and message_id.strip(): + return message_id.strip() + + body_metadata = body.get("metadata", {}) + if isinstance(body_metadata, dict): + message_id = body_metadata.get("message_id") + if isinstance(message_id, str) and message_id.strip(): + return message_id.strip() + + if isinstance(metadata, dict): + message_id = metadata.get("message_id") + if isinstance(message_id, str) and message_id.strip(): + return message_id.strip() + + return "" +``` + +#### 参考实现 + +- `plugins/actions/smart-mind-map/smart_mind_map.py` - 思维导图图片模式实现 +- 官方文档: [Backend-Controlled, UI-Compatible API Flow](https://docs.openwebui.com/tutorials/tips/backend-controlled-ui-compatible-api-flow) --- diff --git a/plugins/actions/smart-mind-map/README.md b/plugins/actions/smart-mind-map/README.md index fdfa2ba..e10571e 100644 --- a/plugins/actions/smart-mind-map/README.md +++ b/plugins/actions/smart-mind-map/README.md @@ -1,6 +1,6 @@ # Smart Mind Map - Mind Mapping Generation Plugin -**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.8.2 | **License:** MIT +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.9.1 | **License:** MIT > **Important**: To ensure the maintainability and usability of all plugins, each plugin should be accompanied by clear and comprehensive documentation to ensure its functionality, configuration, and usage are well explained. @@ -20,6 +20,7 @@ Smart Mind Map is a powerful OpenWebUI action plugin that intelligently analyzes - ✅ **Real-time Rendering**: Renders mind maps directly in the chat interface without navigation - ✅ **Export Capabilities**: Supports PNG, SVG code, and Markdown source export - ✅ **Customizable Configuration**: Configurable LLM model, minimum text length, and other parameters +- ✅ **Image Output Mode**: Generate static SVG images embedded directly in Markdown (no interactive HTML) --- @@ -80,6 +81,7 @@ You can adjust the following parameters in the plugin's settings (Valves): | `MIN_TEXT_LENGTH` | `100` | Minimum text length (in characters) required for mind map analysis. Text that's too short cannot generate valid mind maps. | | `CLEAR_PREVIOUS_HTML` | `false` | Whether to clear previous plugin-generated HTML content when generating a new mind map. | | `MESSAGE_COUNT` | `1` | Number of recent messages to use for mind map generation (1-5). | +| `OUTPUT_MODE` | `html` | Output mode: `html` for interactive HTML (default), or `image` to embed as static Markdown image. | --- @@ -277,6 +279,32 @@ This plugin uses only OpenWebUI's built-in dependencies. **No additional package ## Changelog +### v0.9.1 + +**New Feature: Image Output Mode** + +- Added `OUTPUT_MODE` configuration parameter with two options: + - `html` (default): Interactive HTML mind map with full control panel + - `image`: Static SVG image embedded directly in Markdown (uploaded to `/api/v1/files`) +- Image mode features: + - Auto-responsive width (adapts to chat container) + - Automatic theme detection (light/dark) + - Persistent storage via Chat API (survives page refresh) + - Efficient file storage (no huge base64 strings in chat history) + +**Improvements:** + +- Implemented robust Chat API update mechanism with retry logic +- Fixed message persistence using both `messages[]` and `history.messages` +- Added Event API for immediate frontend updates +- Removed unnecessary `SVG_WIDTH` and `SVG_HEIGHT` parameters (now auto-calculated) + +**Technical Details:** + +- Image mode uses `__event_call__` to execute JavaScript in the browser +- SVG is rendered offline, converted to Blob, and uploaded to OpenWebUI Files API +- Updates chat message with `/api/v1/files/{id}/content` URL via OpenWebUI Backend-Controlled API flow + ### v0.8.2 - Removed debug messages from output diff --git a/plugins/actions/smart-mind-map/README_CN.md b/plugins/actions/smart-mind-map/README_CN.md index f99e991..70b79fd 100644 --- a/plugins/actions/smart-mind-map/README_CN.md +++ b/plugins/actions/smart-mind-map/README_CN.md @@ -1,6 +1,6 @@ # 思维导图 - 思维导图生成插件 -**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.8.2 | **许可证:** MIT +**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.9.1 | **许可证:** MIT > **重要提示**:为了确保所有插件的可维护性和易用性,每个插件都应附带清晰、完整的文档,以确保其功能、配置和使用方法得到充分说明。 @@ -20,6 +20,7 @@ - ✅ **实时渲染**:在聊天界面中直接渲染思维导图,无需跳转 - ✅ **导出功能**:支持 PNG、SVG 代码和 Markdown 源码导出 - ✅ **自定义配置**:可配置 LLM 模型、最小文本长度等参数 +- ✅ **图片输出模式**:生成静态 SVG 图片直接嵌入 Markdown(无交互式 HTML) --- @@ -80,6 +81,7 @@ | `MIN_TEXT_LENGTH` | `100` | 进行思维导图分析所需的最小文本长度(字符数)。文本过短将无法生成有效的导图。 | | `CLEAR_PREVIOUS_HTML` | `false` | 在生成新的思维导图时,是否清除之前由插件生成的 HTML 内容。 | | `MESSAGE_COUNT` | `1` | 用于生成思维导图的最近消息数量(1-5)。 | +| `OUTPUT_MODE` | `html` | 输出模式:`html` 为交互式 HTML(默认),`image` 为嵌入静态 Markdown 图片。 | --- @@ -277,6 +279,32 @@ ## 更新日志 +### v0.9.1 + +**新功能:图片输出模式** + +- 新增 `OUTPUT_MODE` 配置参数,支持两种模式: + - `html`(默认):交互式 HTML 思维导图,带完整控制面板 + - `image`:静态 SVG 图片直接嵌入 Markdown(上传至 `/api/v1/files`) +- 图片模式特性: + - 自动响应式宽度(适应聊天容器) + - 自动主题检测(亮色/暗色) + - 通过 Chat API 持久化存储(刷新页面后保留) + - 高效文件存储(聊天记录中无超长 Base64 字符串) + +**改进项:** + +- 实现健壮的 Chat API 更新机制,带重试逻辑 +- 修复消息持久化,同时更新 `messages[]` 和 `history.messages` +- 添加 Event API 实现即时前端更新 +- 移除不必要的 `SVG_WIDTH` 和 `SVG_HEIGHT` 参数(现已自动计算) + +**技术细节:** + +- 图片模式使用 `__event_call__` 在浏览器中执行 JavaScript +- SVG 离屏渲染,转换为 Blob,并上传至 OpenWebUI Files API +- 通过 OpenWebUI Backend-Controlled API 流程更新聊天消息为 `/api/v1/files/{id}/content` URL + ### v0.8.2 - 移除输出中的调试信息 diff --git a/plugins/actions/smart-mind-map/smart_mind_map.py b/plugins/actions/smart-mind-map/smart_mind_map.py index 28bd266..c7ec689 100644 --- a/plugins/actions/smart-mind-map/smart_mind_map.py +++ b/plugins/actions/smart-mind-map/smart_mind_map.py @@ -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.9.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. """ @@ -790,14 +790,6 @@ class Action: default="html", description="Output mode: 'html' for interactive HTML (default), or 'image' to embed as Markdown image.", ) - SVG_WIDTH: int = Field( - default=1200, - description="Width of the SVG canvas in pixels (for image mode).", - ) - SVG_HEIGHT: int = Field( - default=800, - description="Height of the SVG canvas in pixels (for image mode).", - ) def __init__(self): self.valves = self.Valves() @@ -959,27 +951,83 @@ class Action: chat_id: str, message_id: str, markdown_syntax: str, - svg_width: int, - svg_height: int, ) -> str: """Generate JavaScript code for frontend SVG rendering and image embedding""" - + # Escape the syntax for JS embedding syntax_escaped = ( - markdown_syntax - .replace("\\", "\\\\") + markdown_syntax.replace("\\", "\\\\") .replace("`", "\\`") .replace("${", "\\${") .replace("", "<\\/script>") ) - + return f""" (async function() {{ const uniqueId = "{unique_id}"; const chatId = "{chat_id}"; const messageId = "{message_id}"; - const defaultWidth = {svg_width}; - const defaultHeight = {svg_height}; + 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; @@ -1054,7 +1102,7 @@ class Action: svgEl.setAttribute('height', svgHeight); svgEl.style.width = svgWidth + 'px'; svgEl.style.height = svgHeight + 'px'; - svgEl.style.backgroundColor = '#ffffff'; + svgEl.style.backgroundColor = colors.background; container.appendChild(svgEl); // Transform markdown to tree @@ -1082,23 +1130,23 @@ class Action: clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); - // Add background rect + // 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', '#ffffff'); + bgRect.setAttribute('fill', colors.background); clonedSvg.insertBefore(bgRect, clonedSvg.firstChild); - // Add inline styles + // 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: #000000; }} - foreignObject, .markmap-foreign, .markmap-foreign div {{ color: #000000; font-family: sans-serif; font-size: 14px; }} + 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: #546e7a; fill: none; }} - .markmap-node circle, .markmap-node rect {{ stroke: #94a3b8; }} + .markmap-link {{ stroke: ${{colors.link}}; fill: none; }} + .markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }} `; clonedSvg.insertBefore(style, bgRect.nextSibling); @@ -1110,7 +1158,7 @@ class Action: 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', '#000000'); + textEl.setAttribute('fill', colors.text); textEl.setAttribute('font-family', 'sans-serif'); textEl.setAttribute('font-size', '14'); textEl.textContent = text.trim(); @@ -1120,20 +1168,61 @@ class Action: // Serialize SVG to string const svgData = new XMLSerializer().serializeToString(clonedSvg); - const svgBase64 = btoa(unescape(encodeURIComponent(svgData))); - const dataUrl = 'data:image/svg+xml;base64,' + svgBase64; - console.log("[MindMap Image] Data URL generated, length:", dataUrl.length); - - // Cleanup + // Cleanup container document.body.removeChild(container); - // Generate markdown image - const markdownImage = `![🧠 Mind Map](${{dataUrl}})`; + // Convert SVG string to Blob + const blob = new Blob([svgData], {{ type: 'image/svg+xml' }}); + const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }}); + + // Upload file to OpenWebUI API + console.log("[MindMap Image] Uploading SVG file..."); + const token = localStorage.getItem("token"); + const formData = new FormData(); + formData.append('file', file); + + const uploadResponse = await fetch('/api/v1/files/', {{ + method: 'POST', + headers: {{ + 'Authorization': `Bearer ${{token}}` + }}, + body: formData + }}); + + if (!uploadResponse.ok) {{ + throw new Error(`Upload failed: ${{uploadResponse.statusText}}`); + }} + + const fileData = await uploadResponse.json(); + const fileId = fileData.id; + const imageUrl = `/api/v1/files/${{fileId}}/content`; + + console.log("[MindMap Image] File uploaded, ID:", fileId); + + // Generate markdown image with file URL + const markdownImage = `![🧠 Mind Map](${{imageUrl}})`; // Update message via API if (chatId && messageId) {{ - const token = localStorage.getItem("token"); + + // 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}}`, {{ @@ -1146,24 +1235,26 @@ class Action: }} const chatData = await getResponse.json(); - let originalContent = ""; let updatedMessages = []; + let newContent = ""; if (chatData.chat && chatData.chat.messages) {{ updatedMessages = chatData.chat.messages.map(m => {{ if (m.id === messageId) {{ - originalContent = m.content || ""; - // Remove existing mindmap images - const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\(data:image\\/[^)]+\\)/g; + 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 - const newContent = cleanedContent + "\\n\\n" + markdownImage; + newContent = cleanedContent + "\\n\\n" + markdownImage; // Critical: Update content in both messages array AND history object - // The history object is often the source of truth for the database - if (chatData.chat.history && chatData.chat.history.messages && chatData.chat.history.messages[messageId]) {{ - chatData.chat.history.messages[messageId].content = newContent; + // 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 }}; @@ -1172,28 +1263,40 @@ class Action: }}); }} - // First: Update frontend display via event API (for immediate visual feedback) - 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: updatedMessages.find(m => m.id === messageId)?.content || "" }} - }}) - }}); + if (!newContent) {{ + console.warn("[MindMap Image] Could not find message to update"); + return; + }} - // Second: Persist to database by updating the entire chat + // 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 fetch(`/api/v1/chats/${{chatId}}`, {{ + const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{ method: "POST", headers: {{ "Content-Type": "application/json", @@ -1202,22 +1305,13 @@ class Action: body: JSON.stringify(updatePayload) }}); - if (persistResponse.ok) {{ + if (persistResponse && persistResponse.ok) {{ console.log("[MindMap Image] ✅ Message persisted successfully!"); }} else {{ - console.error("[MindMap Image] Persist API error:", persistResponse.status); - // Try alternative update method - const altResponse = await fetch(`/api/v1/chats/${{chatId}}/share`, {{ - method: "POST", - headers: {{ - "Content-Type": "application/json", - "Authorization": `Bearer ${{token}}` - }} - }}); - console.log("[MindMap Image] Alt persist attempted:", altResponse.status); + console.error("[MindMap Image] ❌ Failed to persist message after retries"); }} }} else {{ - console.warn("[MindMap Image] ⚠️ Missing chatId or messageId"); + console.warn("[MindMap Image] ⚠️ Missing chatId or messageId, cannot persist"); }} }} catch (error) {{ @@ -1235,7 +1329,7 @@ class Action: __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"] @@ -1422,30 +1516,28 @@ class Action: # 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, - svg_width=self.valves.SVG_WIDTH, - svg_height=self.valves.SVG_HEIGHT, ) - + await __event_call__( { "type": "execute", "data": {"code": js_code}, } ) - + await self._emit_status( __event_emitter__, "Smart Mind Map: Image generated!", True ) @@ -1454,9 +1546,9 @@ class Action: f"Mind map image has been generated, {user_name}!", "success", ) - logger.info("Action: Smart Mind Map (v0.9.0) completed in image mode") + 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}" @@ -1469,7 +1561,7 @@ class Action: f"Mind map has been generated, {user_name}!", "success", ) - logger.info("Action: Smart Mind Map (v0.9.0) completed in HTML mode") + 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)}" diff --git a/plugins/actions/smart-mind-map/smart_mind_map_cn.py b/plugins/actions/smart-mind-map/smart_mind_map_cn.py index 41b28b0..dad85a1 100644 --- a/plugins/actions/smart-mind-map/smart_mind_map_cn.py +++ b/plugins/actions/smart-mind-map/smart_mind_map_cn.py @@ -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.9.0 +version: 0.9.1 icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4= description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。 """ @@ -789,14 +789,6 @@ class Action: default="html", description="输出模式: 'html' 为交互式HTML(默认),'image' 为嵌入Markdown图片。", ) - SVG_WIDTH: int = Field( - default=1200, - description="SVG画布宽度(像素,用于图片模式)。", - ) - SVG_HEIGHT: int = Field( - default=800, - description="SVG画布高度(像素,用于图片模式)。", - ) def __init__(self): self.valves = self.Valves() @@ -870,9 +862,7 @@ class Action: 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>") @@ -958,27 +948,83 @@ class Action: chat_id: str, message_id: str, markdown_syntax: str, - svg_width: int, - svg_height: int, ) -> str: """生成用于前端 SVG 渲染和图片嵌入的 JavaScript 代码""" - + # 转义语法以便嵌入 JS syntax_escaped = ( - markdown_syntax - .replace("\\", "\\\\") + markdown_syntax.replace("\\", "\\\\") .replace("`", "\\`") .replace("${", "\\${") .replace("", "<\\/script>") ) - + return f""" (async function() {{ const uniqueId = "{unique_id}"; const chatId = "{chat_id}"; const messageId = "{message_id}"; - const defaultWidth = {svg_width}; - const defaultHeight = {svg_height}; + 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; @@ -1053,7 +1099,7 @@ class Action: svgEl.setAttribute('height', svgHeight); svgEl.style.width = svgWidth + 'px'; svgEl.style.height = svgHeight + 'px'; - svgEl.style.backgroundColor = '#ffffff'; + svgEl.style.backgroundColor = colors.background; container.appendChild(svgEl); // 将 markdown 转换为树结构 @@ -1081,23 +1127,23 @@ class Action: 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', '#ffffff'); + 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: #000000; }} - foreignObject, .markmap-foreign, .markmap-foreign div {{ color: #000000; font-family: sans-serif; font-size: 14px; }} + 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: #546e7a; fill: none; }} - .markmap-node circle, .markmap-node rect {{ stroke: #94a3b8; }} + .markmap-link {{ stroke: ${{colors.link}}; fill: none; }} + .markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }} `; clonedSvg.insertBefore(style, bgRect.nextSibling); @@ -1109,7 +1155,7 @@ class Action: 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', '#000000'); + textEl.setAttribute('fill', colors.text); textEl.setAttribute('font-family', 'sans-serif'); textEl.setAttribute('font-size', '14'); textEl.textContent = text.trim(); @@ -1119,21 +1165,63 @@ class Action: // 序列化 SVG 为字符串 const svgData = new XMLSerializer().serializeToString(clonedSvg); - const svgBase64 = btoa(unescape(encodeURIComponent(svgData))); - const dataUrl = 'data:image/svg+xml;base64,' + svgBase64; - console.log("[思维导图图片] Data URL 已生成,长度:", dataUrl.length); - - // 清理 + // 清理容器 document.body.removeChild(container); - // 生成 markdown 图片 - const markdownImage = `![🧠 思维导图](${{dataUrl}})`; + // 将 SVG 字符串转换为 Blob + const blob = new Blob([svgData], {{ type: 'image/svg+xml' }}); + const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }}); + + // 上传文件到 OpenWebUI API + console.log("[思维导图图片] 正在上传 SVG 文件..."); + const token = localStorage.getItem("token"); + const formData = new FormData(); + formData.append('file', file); + + const uploadResponse = await fetch('/api/v1/files/', {{ + method: 'POST', + headers: {{ + 'Authorization': `Bearer ${{token}}` + }}, + body: formData + }}); + + if (!uploadResponse.ok) {{ + throw new Error(`上传失败: ${{uploadResponse.statusText}}`); + }} + + const fileData = await uploadResponse.json(); + const fileId = fileData.id; + const imageUrl = `/api/v1/files/${{fileId}}/content`; + + console.log("[思维导图图片] 文件已上传, ID:", fileId); + + // 生成包含文件 URL 的 markdown 图片 + const markdownImage = `![🧠 思维导图](${{imageUrl}})`; // 通过 API 更新消息 if (chatId && messageId) {{ const token = localStorage.getItem("token"); + // 带重试逻辑的请求函数 + const fetchWithRetry = async (url, options, retries = 3) => {{ + for (let i = 0; i < retries; i++) {{ + try {{ + const response = await fetch(url, options); + if (response.ok) return response; + if (i < retries - 1) {{ + console.log(`[思维导图图片] 重试 ${{i + 1}}/${{retries}}: ${{url}}`); + await new Promise(r => setTimeout(r, 1000 * (i + 1))); + }} + }} catch (e) {{ + if (i === retries - 1) throw e; + await new Promise(r => setTimeout(r, 1000 * (i + 1))); + }} + }} + return null; + }}; + // 获取当前聊天数据 const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ method: "GET", @@ -1145,24 +1233,26 @@ class Action: }} const chatData = await getResponse.json(); - let originalContent = ""; let updatedMessages = []; + let newContent = ""; if (chatData.chat && chatData.chat.messages) {{ updatedMessages = chatData.chat.messages.map(m => {{ if (m.id === messageId) {{ - originalContent = m.content || ""; - // 移除已有的思维导图图片 - const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\(data:image\\/[^)]+\\)/g; + 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(); // 追加新图片 - const newContent = cleanedContent + "\\n\\n" + markdownImage; + newContent = cleanedContent + "\\n\\n" + markdownImage; // 关键: 同时更新 messages 数组和 history 对象中的内容 - // history 对象通常是数据库的单一真值来源 - if (chatData.chat.history && chatData.chat.history.messages && chatData.chat.history.messages[messageId]) {{ - chatData.chat.history.messages[messageId].content = newContent; + // 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 }}; @@ -1171,28 +1261,40 @@ class Action: }}); }} - // 第一步: 通过事件 API 更新前端显示(立即视觉反馈) - 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: updatedMessages.find(m => m.id === messageId)?.content || "" }} - }}) - }}); + 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 fetch(`/api/v1/chats/${{chatId}}`, {{ + const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{ method: "POST", headers: {{ "Content-Type": "application/json", @@ -1201,22 +1303,13 @@ class Action: body: JSON.stringify(updatePayload) }}); - if (persistResponse.ok) {{ + if (persistResponse && persistResponse.ok) {{ console.log("[思维导图图片] ✅ 消息已持久化保存!"); }} else {{ - console.error("[思维导图图片] 持久化 API 错误:", persistResponse.status); - // 尝试备用更新方法 - const altResponse = await fetch(`/api/v1/chats/${{chatId}}/share`, {{ - method: "POST", - headers: {{ - "Content-Type": "application/json", - "Authorization": `Bearer ${{token}}` - }} - }}); - console.log("[思维导图图片] 备用持久化尝试:", altResponse.status); + console.error("[思维导图图片] ❌ 重试后仍然无法持久化消息"); }} }} else {{ - console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId"); + console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId,无法持久化"); }} }} catch (error) {{ @@ -1234,7 +1327,7 @@ class Action: __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"] @@ -1416,30 +1509,28 @@ class Action: # 图片模式: 使用 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, - svg_width=self.valves.SVG_WIDTH, - svg_height=self.valves.SVG_HEIGHT, ) - + await __event_call__( { "type": "execute", "data": {"code": js_code}, } ) - + await self._emit_status( __event_emitter__, "思维导图: 图片已生成!", True ) @@ -1448,9 +1539,9 @@ class Action: f"思维导图图片已生成,{user_name}!", "success", ) - logger.info("Action: 思维导图 (v0.9.0) 图片模式完成") + 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}" @@ -1459,7 +1550,7 @@ class Action: await self._emit_notification( __event_emitter__, f"思维导图已生成,{user_name}!", "success" ) - logger.info("Action: 思维导图 (v0.9.0) HTML 模式完成") + logger.info("Action: 思维导图 (v0.9.1) HTML 模式完成") except Exception as e: error_message = f"思维导图处理失败: {str(e)}"