From 8471680efedc03e6f96dc6eba59b60772fec3e80 Mon Sep 17 00:00:00 2001 From: fujie Date: Tue, 6 Jan 2026 19:26:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=8F=B0=20=E6=97=B6=E9=97=B4=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=94=B9=E4=B8=BA=E5=8C=97=E4=BA=AC=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=A1=AE=E5=88=B0=E5=88=86=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有时间戳使用北京时区 (UTC+8) - 格式从 YYYY-MM-DD 改为 YYYY-MM-DD HH:MM - 添加 '(北京时间)' 标注 --- README.md | 8 +- README_CN.md | 2 +- docs/community-stats.json | 2 +- .../actions/smart-mind-map/smart_mind_map.py | 374 +++++++++++++++- .../smart-mind-map/smart_mind_map_cn.py | 408 +++++++++++++++++- scripts/openwebui_stats.py | 21 +- 6 files changed, 783 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 0876641..2900786 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu ## 📊 Community Stats -> 🕐 Auto-updated on 2026-01-06 +> 🕐 Auto-updated: 2026-01-06 19:26 (Beijing Time) | 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions | |:---:|:---:|:---:|:---:| @@ -16,6 +16,7 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu | 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves | |:---:|:---:|:---:|:---:|:---:| | **11** | **785** | **8394** | **54** | **46** | +| **11** | **785** | **8411** | **54** | **47** | ### 🔥 Top 5 Popular Plugins @@ -26,6 +27,11 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu | 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1234 | | 4️⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 75 | 1413 | | 5️⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 900 | +| 🥇 | [Turn Any Text into Beautiful Mind Maps](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) | 235 | 2103 | +| 🥈 | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) | 170 | 456 | +| 🥉 | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) | 112 | 1235 | +| 4️⃣ | [Flash Card ](https://openwebui.com/posts/flash_card_65a2ea8f) | 75 | 1414 | +| 5️⃣ | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) | 65 | 904 | *See full stats in [Community Stats Report](./docs/community-stats.md)* diff --git a/README_CN.md b/README_CN.md index 87c5f50..66aa1f4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -7,7 +7,7 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词 ## 📊 社区统计 -> 🕐 自动更新于 2026-01-06 +> 🕐 自动更新于 2026-01-06 19:26 (北京时间) | 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 | |:---:|:---:|:---:|:---:| diff --git a/docs/community-stats.json b/docs/community-stats.json index ee07809..ed46ec8 100644 --- a/docs/community-stats.json +++ b/docs/community-stats.json @@ -85,7 +85,7 @@ "downloads": 65, "views": 900, "upvotes": 6, - "saves": 7, + "saves": 8, "comments": 2, "created_at": "2025-12-28", "updated_at": "2026-01-03", diff --git a/plugins/actions/smart-mind-map/smart_mind_map.py b/plugins/actions/smart-mind-map/smart_mind_map.py index cb07244..28bd266 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.8.2 +version: 0.9.0 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,18 @@ 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.", + ) + 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() @@ -814,6 +826,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,11 +953,286 @@ class Action: return base_html.strip() + def _generate_image_js_code( + self, + unique_id: str, + 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("\\", "\\\\") + .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}; + + // 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 = '#ffffff'; + 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 + const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bgRect.setAttribute('width', '100%'); + bgRect.setAttribute('height', '100%'); + bgRect.setAttribute('fill', '#ffffff'); + clonedSvg.insertBefore(bgRect, clonedSvg.firstChild); + + // Add inline styles + 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; }} + 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; }} + `; + 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', '#000000'); + 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); + 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 + document.body.removeChild(container); + + // Generate markdown image + const markdownImage = `![🧠 Mind Map](${{dataUrl}})`; + + // Update message via API + if (chatId && messageId) {{ + const token = localStorage.getItem("token"); + + // 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 originalContent = ""; + let updatedMessages = []; + + 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; + let cleanedContent = originalContent.replace(mindmapPattern, ""); + cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim(); + // Append new image + const 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; + }} + + return {{ ...m, content: newContent }}; + }} + return m; + }}); + }} + + // 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 || "" }} + }}) + }}); + + // Second: Persist to database by updating the entire chat + const updatePayload = {{ + chat: {{ + ...chatData.chat, + messages: updatedMessages + }} + }}; + + const persistResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ + method: "POST", + headers: {{ + "Content-Type": "application/json", + "Authorization": `Bearer ${{token}}` + }}, + body: JSON.stringify(updatePayload) + }}); + + if (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); + }} + }} else {{ + console.warn("[MindMap Image] ⚠️ Missing chatId or messageId"); + }} + + }} 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") @@ -1090,6 +1417,47 @@ 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, + 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 + ) + await self._emit_notification( + __event_emitter__, + f"Mind map image has been generated, {user_name}!", + "success", + ) + logger.info("Action: Smart Mind Map (v0.9.0) 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 +1469,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.0) 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 9386e47..41b28b0 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.8.2 +version: 0.9.0 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 = '
⚠️ 无法加载思维导图:缺少有效内容。
'; + containerEl.innerHTML = '
⚠️ 无法加载思维导图:缺少有效内容。
'; return; } @@ -485,7 +485,7 @@ SCRIPT_TEMPLATE_MINDMAP = """ }).catch((error) => { console.error('Markmap loading error:', error); - containerEl.innerHTML = '
⚠️ 资源加载失败,请稍后重试。
'; + containerEl.innerHTML = '
⚠️ 资源加载失败,请稍后重试。
'; }); }; @@ -771,19 +771,31 @@ 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图片。", + ) + SVG_WIDTH: int = Field( + default=1200, + description="SVG画布宽度(像素,用于图片模式)。", + ) + SVG_HEIGHT: int = Field( + default=800, + description="SVG画布高度(像素,用于图片模式)。", ) def __init__(self): @@ -813,13 +825,53 @@ 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格式,将整个输出作为摘要处理。" + "LLM输出未严格遵循预期Markdown格式,将整个输出作为摘要处理。" ) extracted_content = llm_output.strip() return extracted_content.replace("", "<\\/script>") @@ -844,7 +896,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 +919,7 @@ class Action: user_language: str = "zh-CN", ) -> str: """ - 将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。 + 将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。 """ if ( "" in existing_html_code @@ -900,11 +952,286 @@ class Action: return base_html.strip() + def _generate_image_js_code( + self, + unique_id: str, + chat_id: str, + message_id: str, + markdown_syntax: str, + svg_width: int, + svg_height: int, + ) -> str: + """生成用于前端 SVG 渲染和图片嵌入的 JavaScript 代码""" + + # 转义语法以便嵌入 JS + syntax_escaped = ( + 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}; + + // 自动检测聊天容器宽度以实现自适应 + 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 = '#ffffff'; + 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', '#ffffff'); + 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; }} + 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; }} + `; + 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', '#000000'); + 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); + 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}})`; + + // 通过 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 = ""; + let updatedMessages = []; + + 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; + let cleanedContent = originalContent.replace(mindmapPattern, ""); + cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim(); + // 追加新图片 + const 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; + }} + + return {{ ...m, content: newContent }}; + }} + return m; + }}); + }} + + // 第一步: 通过事件 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 || "" }} + }}) + }}); + + // 第二步: 通过更新整个聊天来持久化到数据库 + const updatePayload = {{ + chat: {{ + ...chatData.chat, + messages: updatedMessages + }} + }}; + + const persistResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{ + method: "POST", + headers: {{ + "Content-Type": "application/json", + "Authorization": `Bearer ${{token}}` + }}, + body: JSON.stringify(updatePayload) + }}); + + if (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); + }} + }} 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") @@ -923,7 +1250,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 +1258,7 @@ class Action: current_timezone_str = "未知时区" await self._emit_notification( - __event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info" + __event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info" ) messages = body.get("messages") @@ -980,7 +1307,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 +1348,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 +1411,67 @@ 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, + 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 + ) + await self._emit_notification( + __event_emitter__, + f"思维导图图片已生成,{user_name}!", + "success", + ) + logger.info("Action: 思维导图 (v0.9.0) 图片模式完成") + 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.0) 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 diff --git a/scripts/openwebui_stats.py b/scripts/openwebui_stats.py index c1a0dee..56b736f 100644 --- a/scripts/openwebui_stats.py +++ b/scripts/openwebui_stats.py @@ -20,10 +20,19 @@ OpenWebUI 社区统计工具 import os import json import requests -from datetime import datetime +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 @@ -190,7 +199,7 @@ class OpenWebUIStats: print("\n" + "=" * 60) print("📊 OpenWebUI 社区统计报告") print("=" * 60) - print(f"📅 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"📅 生成时间 (北京): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}") print() # 总览 @@ -241,7 +250,7 @@ class OpenWebUIStats: texts = { "zh": { "title": "# 📊 OpenWebUI 社区统计报告", - "updated": f"> 📅 更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + "updated": f"> 📅 更新时间 (北京): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}", "overview_title": "## 📈 总览", "overview_header": "| 指标 | 数值 |", "posts": "📝 发布数量", @@ -256,7 +265,7 @@ class OpenWebUIStats: }, "en": { "title": "# 📊 OpenWebUI Community Stats Report", - "updated": f"> 📅 Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + "updated": f"> 📅 Updated (Beijing Time): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}", "overview_title": "## 📈 Overview", "overview_header": "| Metric | Value |", "posts": "📝 Total Posts", @@ -337,7 +346,7 @@ class OpenWebUIStats: texts = { "zh": { "title": "## 📊 社区统计", - "updated": f"> 🕐 自动更新于 {datetime.now().strftime('%Y-%m-%d')}", + "updated": f"> 🕐 自动更新于 {get_beijing_time().strftime('%Y-%m-%d %H:%M')} (北京时间)", "author_header": "| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |", "header": "| 📝 发布 | ⬇️ 下载 | 👁️ 浏览 | 👍 点赞 | 💾 收藏 |", "top5_title": "### 🔥 热门插件 Top 5", @@ -346,7 +355,7 @@ class OpenWebUIStats: }, "en": { "title": "## 📊 Community Stats", - "updated": f"> 🕐 Auto-updated on {datetime.now().strftime('%Y-%m-%d')}", + "updated": f"> 🕐 Auto-updated: {get_beijing_time().strftime('%Y-%m-%d %H:%M')} (Beijing Time)", "author_header": "| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |", "header": "| 📝 Posts | ⬇️ Downloads | 👁️ Views | 👍 Upvotes | 💾 Saves |", "top5_title": "### 🔥 Top 5 Popular Plugins",