feat(smart-mind-map): v0.9.1 - Add Image output mode with file upload support

This commit is contained in:
fujie
2026-01-06 21:35:27 +08:00
parent 28bb9000d8
commit 856f76cd27
6 changed files with 597 additions and 162 deletions

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ title: Smart Mind Map
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.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>", "<\\/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)}"

View File

@@ -3,7 +3,7 @@ title: 思维导图
author: Fu-Jie
author_url: https://github.com/Fu-Jie
funding_url: https://github.com/Fu-Jie/awesome-openwebui
version: 0.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>", "<\\/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>", "<\\/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)}"