1569 lines
67 KiB
Python
1569 lines
67 KiB
Python
"""
|
|
title: 思维导图
|
|
author: Fu-Jie
|
|
author_url: https://github.com/Fu-Jie
|
|
funding_url: https://github.com/Fu-Jie/awesome-openwebui
|
|
version: 0.9.1
|
|
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
|
|
description: 智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Callable, Awaitable, Dict, Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from open_webui.utils.chat import generate_chat_completion
|
|
from open_webui.models.users import Users
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SYSTEM_PROMPT_MINDMAP_ASSISTANT = """
|
|
你是一个专业的思维导图生成助手,能够高效地分析用户提供的长篇文本,并将其核心主题、关键概念、分支和子分支结构化为标准的Markdown列表语法,以便Markmap.js进行渲染。
|
|
|
|
请严格遵循以下指导原则:
|
|
- **语言**: 所有输出必须使用用户指定的语言。
|
|
- **格式**: 你的输出必须严格为Markdown列表格式,并用```markdown 和 ``` 包裹。
|
|
- 使用 `#` 定义中心主题(根节点)。
|
|
- 使用 `-` 和两个空格的缩进表示分支和子分支。
|
|
- **内容**:
|
|
- 识别文本的中心主题作为 `#` 标题。
|
|
- 识别主要概念作为一级列表项。
|
|
- 识别支持性细节或子概念作为嵌套的列表项。
|
|
- 节点内容应简洁明了,避免冗长。
|
|
- **只输出Markdown语法**: 不要包含任何额外的寒暄、解释或引导性文字。
|
|
- **如果文本过短或无法生成有效导图**: 请输出一个简单的Markdown列表,表示无法生成,例如:
|
|
```markdown
|
|
# 无法生成思维导图
|
|
- 原因: 文本内容不足或不明确
|
|
```
|
|
"""
|
|
|
|
USER_PROMPT_GENERATE_MINDMAP = """
|
|
请分析以下长篇文本,并将其核心主题、关键概念、分支和子分支结构化为标准的Markdown列表语法,以供Markmap.js渲染。
|
|
|
|
---
|
|
**用户上下文信息:**
|
|
用户姓名: {user_name}
|
|
当前日期时间: {current_date_time_str}
|
|
当前星期: {current_weekday}
|
|
当前时区: {current_timezone_str}
|
|
用户语言: {user_language}
|
|
---
|
|
|
|
**长篇文本内容:**
|
|
{long_text_content}
|
|
"""
|
|
|
|
HTML_WRAPPER_TEMPLATE = """
|
|
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
|
<!DOCTYPE html>
|
|
<html lang="{user_language}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
margin: 0;
|
|
padding: 10px;
|
|
background-color: transparent;
|
|
}
|
|
#main-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
align-items: stretch;
|
|
width: 100%;
|
|
}
|
|
.plugin-item {
|
|
width: 100%;
|
|
border-radius: 12px;
|
|
overflow: visible;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.plugin-item:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
/* STYLES_INSERTION_POINT */
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="main-container">
|
|
<!-- CONTENT_INSERTION_POINT -->
|
|
</div>
|
|
<!-- SCRIPTS_INSERTION_POINT -->
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
CSS_TEMPLATE_MINDMAP = """
|
|
:root {
|
|
--primary-color: #1e88e5;
|
|
--secondary-color: #43a047;
|
|
--background-color: #f4f6f8;
|
|
--card-bg-color: #ffffff;
|
|
--text-color: #000000;
|
|
--link-color: #546e7a;
|
|
--node-stroke-color: #90a4ae;
|
|
--muted-text-color: #546e7a;
|
|
--border-color: #e0e0e0;
|
|
--header-gradient: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
|
--shadow: 0 10px 20px rgba(0, 0, 0, 0.06);
|
|
--border-radius: 12px;
|
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
}
|
|
.theme-dark {
|
|
--primary-color: #64b5f6;
|
|
--secondary-color: #81c784;
|
|
--background-color: #111827;
|
|
--card-bg-color: #1f2937;
|
|
--text-color: #ffffff;
|
|
--link-color: #cbd5e1;
|
|
--node-stroke-color: #94a3b8;
|
|
--muted-text-color: #9ca3af;
|
|
--border-color: #374151;
|
|
--header-gradient: linear-gradient(135deg, #0ea5e9, #22c55e);
|
|
--shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
|
}
|
|
.mindmap-container-wrapper {
|
|
font-family: var(--font-family);
|
|
line-height: 1.6;
|
|
color: var(--text-color);
|
|
margin: 0;
|
|
padding: 0;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--background-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.header {
|
|
background: var(--header-gradient);
|
|
color: white;
|
|
padding: 18px 20px;
|
|
text-align: center;
|
|
border-top-left-radius: var(--border-radius);
|
|
border-top-right-radius: var(--border-radius);
|
|
}
|
|
.header h1 {
|
|
margin: 0;
|
|
font-size: 1.4em;
|
|
font-weight: 600;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
.user-context {
|
|
font-size: 0.85em;
|
|
color: var(--muted-text-color);
|
|
background-color: rgba(255, 255, 255, 0.6);
|
|
padding: 8px 14px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
border-bottom: 1px solid var(--border-color);
|
|
gap: 6px;
|
|
}
|
|
.theme-dark .user-context {
|
|
background-color: rgba(31, 41, 55, 0.7);
|
|
}
|
|
.user-context span { margin: 2px 6px; }
|
|
.content-area {
|
|
padding: 16px;
|
|
flex-grow: 1;
|
|
background: var(--card-bg-color);
|
|
}
|
|
.markmap-container {
|
|
position: relative;
|
|
background-color: var(--card-bg-color);
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
border: 1px solid var(--border-color);
|
|
width: 100%;
|
|
min-height: 60vh;
|
|
overflow: visible;
|
|
}
|
|
.markmap-container svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.markmap-container svg text {
|
|
fill: var(--text-color) !important;
|
|
font-family: var(--font-family);
|
|
}
|
|
.markmap-container svg foreignObject,
|
|
.markmap-container svg .markmap-foreign,
|
|
.markmap-container svg .markmap-foreign div {
|
|
color: var(--text-color) !important;
|
|
font-family: var(--font-family);
|
|
}
|
|
.markmap-container svg .markmap-link {
|
|
stroke: var(--link-color) !important;
|
|
}
|
|
.markmap-container svg .markmap-node circle,
|
|
.markmap-container svg .markmap-node rect {
|
|
stroke: var(--node-stroke-color) !important;
|
|
}
|
|
.control-rows {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
justify-content: center;
|
|
margin-top: 12px;
|
|
}
|
|
.btn-group {
|
|
display: inline-flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
.control-btn {
|
|
background-color: var(--primary-color);
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
font-size: 0.9em;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease, transform 0.15s ease;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
height: 36px;
|
|
box-sizing: border-box;
|
|
}
|
|
select.control-btn {
|
|
appearance: none;
|
|
padding-right: 28px;
|
|
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23FFFFFF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 8px center;
|
|
background-size: 10px;
|
|
}
|
|
.control-btn.secondary { background-color: var(--secondary-color); }
|
|
.control-btn.neutral { background-color: #64748b; }
|
|
.control-btn:hover { transform: translateY(-1px); }
|
|
.control-btn.copied { background-color: #2e7d32; }
|
|
.control-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
.footer {
|
|
text-align: center;
|
|
padding: 12px;
|
|
font-size: 0.85em;
|
|
color: var(--muted-text-color);
|
|
background-color: var(--card-bg-color);
|
|
border-top: 1px solid var(--border-color);
|
|
border-bottom-left-radius: var(--border-radius);
|
|
border-bottom-right-radius: var(--border-radius);
|
|
}
|
|
|
|
.footer a {
|
|
color: var(--primary-color);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
.footer a:hover { text-decoration: underline; }
|
|
.error-message {
|
|
color: #c62828;
|
|
background-color: #ffcdd2;
|
|
border: 1px solid #ef9a9a;
|
|
padding: 14px;
|
|
border-radius: 8px;
|
|
font-weight: 500;
|
|
font-size: 1em;
|
|
}
|
|
"""
|
|
|
|
CONTENT_TEMPLATE_MINDMAP = """
|
|
<div class="mindmap-container-wrapper">
|
|
<div class="header">
|
|
<h1>🧠 智能思维导图</h1>
|
|
</div>
|
|
<div class="user-context">
|
|
<span><strong>用户:</strong> {user_name}</span>
|
|
<span><strong>时间:</strong> {current_date_time_str}</span>
|
|
</div>
|
|
<div class="content-area">
|
|
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
|
<div class="control-rows">
|
|
<div class="btn-group">
|
|
<button id="download-png-btn-{unique_id}" class="control-btn secondary">
|
|
<span class="btn-text">PNG</span>
|
|
</button>
|
|
<button id="download-svg-btn-{unique_id}" class="control-btn">
|
|
<span class="btn-text">SVG</span>
|
|
</button>
|
|
<button id="download-md-btn-{unique_id}" class="control-btn neutral">
|
|
<span class="btn-text">Markdown</span>
|
|
</button>
|
|
</div>
|
|
<div class="btn-group">
|
|
<button id="zoom-out-btn-{unique_id}" class="control-btn neutral" title="缩小">-</button>
|
|
<button id="zoom-reset-btn-{unique_id}" class="control-btn neutral" title="重置">重置</button>
|
|
<button id="zoom-in-btn-{unique_id}" class="control-btn neutral" title="放大">+</button>
|
|
</div>
|
|
<div class="btn-group">
|
|
<select id="depth-select-{unique_id}" class="control-btn secondary" title="展开层级">
|
|
<option value="0" selected>全部展开</option>
|
|
<option value="2">展开 2 级</option>
|
|
<option value="3">展开 3 级</option>
|
|
</select>
|
|
<button id="fullscreen-btn-{unique_id}" class="control-btn">全屏</button>
|
|
<button id="theme-toggle-btn-{unique_id}" class="control-btn neutral">主题</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="footer">
|
|
<p>© {current_year} 智能思维导图 • <a href="https://markmap.js.org/" target="_blank">Markmap</a></p>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
|
"""
|
|
|
|
SCRIPT_TEMPLATE_MINDMAP = """
|
|
<script>
|
|
(function() {
|
|
const uniqueId = "{unique_id}";
|
|
|
|
const loadScriptOnce = (src, checkFn) => {
|
|
if (checkFn()) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
const existing = document.querySelector(`script[data-src="${src}"]`);
|
|
if (existing) {
|
|
existing.addEventListener('load', () => resolve());
|
|
existing.addEventListener('error', () => reject(new Error('加载失败: ' + src)));
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.async = true;
|
|
script.dataset.src = src;
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error('加载失败: ' + src));
|
|
document.head.appendChild(script);
|
|
});
|
|
};
|
|
|
|
const ensureMarkmapReady = () =>
|
|
loadScriptOnce('https://cdn.jsdelivr.net/npm/d3@7', () => window.d3)
|
|
.then(() => loadScriptOnce('https://cdn.jsdelivr.net/npm/markmap-lib@0.17', () => window.markmap && window.markmap.Transformer))
|
|
.then(() => loadScriptOnce('https://cdn.jsdelivr.net/npm/markmap-view@0.17', () => window.markmap && window.markmap.Markmap));
|
|
|
|
const parseColorLuma = (colorStr) => {
|
|
if (!colorStr) return null;
|
|
// hex #rrggbb or rrggbb
|
|
let m = colorStr.match(/^#?([0-9a-f]{6})$/i);
|
|
if (m) {
|
|
const hex = m[1];
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
}
|
|
// rgb(r, g, b) or rgba(r, g, b, a)
|
|
m = colorStr.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
if (m) {
|
|
const r = parseInt(m[1], 10);
|
|
const g = parseInt(m[2], 10);
|
|
const b = parseInt(m[3], 10);
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getThemeFromMeta = (doc, scope = 'self') => {
|
|
const metas = Array.from((doc || document).querySelectorAll('meta[name="theme-color"]'));
|
|
if (!metas.length) return null;
|
|
const color = metas[metas.length - 1].content.trim();
|
|
const luma = parseColorLuma(color);
|
|
if (luma === null) return null;
|
|
return luma < 0.5 ? 'dark' : 'light';
|
|
};
|
|
|
|
const getParentDocumentSafe = () => {
|
|
try {
|
|
if (!window.parent || window.parent === window) return null;
|
|
const pDoc = window.parent.document;
|
|
void pDoc.title;
|
|
return pDoc;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getThemeFromParentClass = () => {
|
|
try {
|
|
if (!window.parent || window.parent === window) return null;
|
|
const pDoc = window.parent.document;
|
|
const html = pDoc.documentElement;
|
|
const body = pDoc.body;
|
|
const htmlClass = html ? html.className : '';
|
|
const bodyClass = body ? body.className : '';
|
|
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
|
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) return 'dark';
|
|
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) return 'light';
|
|
return null;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const setTheme = (wrapperEl, explicitTheme) => {
|
|
const parentDoc = getParentDocumentSafe();
|
|
const metaThemeParent = parentDoc ? getThemeFromMeta(parentDoc, 'parent') : null;
|
|
const parentClassTheme = getThemeFromParentClass();
|
|
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
const chosen = explicitTheme || metaThemeParent || parentClassTheme || (prefersDark ? 'dark' : 'light');
|
|
wrapperEl.classList.toggle('theme-dark', chosen === 'dark');
|
|
return chosen;
|
|
};
|
|
|
|
const renderMindmap = () => {
|
|
const containerEl = document.getElementById('markmap-container-' + uniqueId);
|
|
if (!containerEl || containerEl.dataset.markmapRendered) return;
|
|
|
|
const sourceEl = document.getElementById('markdown-source-' + uniqueId);
|
|
if (!sourceEl) return;
|
|
|
|
const markdownContent = sourceEl.textContent.trim();
|
|
if (!markdownContent) {
|
|
containerEl.innerHTML = '<div class="error-message">⚠️ 无法加载思维导图:缺少有效内容。</div>';
|
|
return;
|
|
}
|
|
|
|
ensureMarkmapReady().then(() => {
|
|
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svgEl.style.width = '100%';
|
|
svgEl.style.height = '100%';
|
|
svgEl.style.minHeight = '60vh';
|
|
containerEl.innerHTML = '';
|
|
containerEl.appendChild(svgEl);
|
|
|
|
const { Transformer, Markmap } = window.markmap;
|
|
const transformer = new Transformer();
|
|
const { root } = transformer.transform(markdownContent);
|
|
|
|
const style = (id) => `
|
|
${id} text, ${id} foreignObject { font-size: 14px; }
|
|
${id} foreignObject h1 { font-size: 22px; font-weight: 700; margin: 0; }
|
|
${id} foreignObject h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
|
${id} foreignObject strong { font-weight: 700; }
|
|
`;
|
|
const options = {
|
|
autoFit: true,
|
|
style: style,
|
|
initialExpandLevel: Infinity,
|
|
zoom: true,
|
|
pan: true
|
|
};
|
|
|
|
const markmapInstance = Markmap.create(svgEl, options, root);
|
|
containerEl.dataset.markmapRendered = 'true';
|
|
|
|
setupControls({
|
|
containerEl,
|
|
svgEl,
|
|
markmapInstance,
|
|
root,
|
|
});
|
|
|
|
}).catch((error) => {
|
|
console.error('Markmap loading error:', error);
|
|
containerEl.innerHTML = '<div class="error-message">⚠️ 资源加载失败,请稍后重试。</div>';
|
|
});
|
|
};
|
|
|
|
const setupControls = ({ containerEl, svgEl, markmapInstance, root }) => {
|
|
const downloadSvgBtn = document.getElementById('download-svg-btn-' + uniqueId);
|
|
const downloadPngBtn = document.getElementById('download-png-btn-' + uniqueId);
|
|
const downloadMdBtn = document.getElementById('download-md-btn-' + uniqueId);
|
|
const zoomInBtn = document.getElementById('zoom-in-btn-' + uniqueId);
|
|
const zoomOutBtn = document.getElementById('zoom-out-btn-' + uniqueId);
|
|
const zoomResetBtn = document.getElementById('zoom-reset-btn-' + uniqueId);
|
|
const depthSelect = document.getElementById('depth-select-' + uniqueId);
|
|
const fullscreenBtn = document.getElementById('fullscreen-btn-' + uniqueId);
|
|
const themeToggleBtn = document.getElementById('theme-toggle-btn-' + uniqueId);
|
|
|
|
const wrapper = containerEl.closest('.mindmap-container-wrapper');
|
|
let currentTheme = setTheme(wrapper);
|
|
|
|
const showFeedback = (button, textOk = '完成', textFail = '失败') => {
|
|
if (!button) return;
|
|
const buttonText = button.querySelector('.btn-text') || button;
|
|
const originalText = buttonText.textContent;
|
|
button.disabled = true;
|
|
buttonText.textContent = textOk;
|
|
button.classList.add('copied');
|
|
setTimeout(() => {
|
|
buttonText.textContent = originalText;
|
|
button.disabled = false;
|
|
button.classList.remove('copied');
|
|
}, 1800);
|
|
};
|
|
|
|
const copyToClipboard = (content, button) => {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(content).then(() => showFeedback(button), () => showFeedback(button, '失败', '失败'));
|
|
} else {
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = content;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.opacity = '0';
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
showFeedback(button);
|
|
} catch (err) {
|
|
showFeedback(button, '失败', '失败');
|
|
}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
};
|
|
|
|
const handleDownloadSVG = () => {
|
|
const svg = containerEl.querySelector('svg');
|
|
if (!svg) return;
|
|
// Inline styles before export
|
|
const clonedSvg = svg.cloneNode(true);
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
text { font-family: sans-serif; fill: ${currentTheme === 'dark' ? '#ffffff' : '#000000'}; }
|
|
foreignObject, .markmap-foreign, .markmap-foreign div { color: ${currentTheme === 'dark' ? '#ffffff' : '#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: ${currentTheme === 'dark' ? '#cbd5e1' : '#546e7a'}; }
|
|
.markmap-node circle, .markmap-node rect { stroke: ${currentTheme === 'dark' ? '#94a3b8' : '#94a3b8'}; }
|
|
`;
|
|
clonedSvg.prepend(style);
|
|
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
|
copyToClipboard(svgData, downloadSvgBtn);
|
|
};
|
|
|
|
const handleDownloadMD = () => {
|
|
const markdownContent = document.getElementById('markdown-source-' + uniqueId)?.textContent || '';
|
|
if (!markdownContent) return;
|
|
copyToClipboard(markdownContent, downloadMdBtn);
|
|
};
|
|
|
|
const handleDownloadPNG = () => {
|
|
const btn = downloadPngBtn;
|
|
const originalText = btn.querySelector('.btn-text').textContent;
|
|
btn.querySelector('.btn-text').textContent = '生成中...';
|
|
btn.disabled = true;
|
|
|
|
const svg = containerEl.querySelector('svg');
|
|
if (!svg) {
|
|
btn.querySelector('.btn-text').textContent = originalText;
|
|
btn.disabled = false;
|
|
showFeedback(btn, '失败', '失败');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Clone SVG and inline styles
|
|
const clonedSvg = svg.cloneNode(true);
|
|
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
|
|
|
const rect = svg.getBoundingClientRect();
|
|
const width = rect.width || 800;
|
|
const height = rect.height || 600;
|
|
clonedSvg.setAttribute('width', width);
|
|
clonedSvg.setAttribute('height', height);
|
|
|
|
// Remove foreignObject (HTML content) and replace with 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', currentTheme === 'dark' ? '#ffffff' : '#000000');
|
|
textEl.setAttribute('font-family', 'sans-serif');
|
|
textEl.setAttribute('font-size', '14');
|
|
textEl.textContent = text.trim();
|
|
g.appendChild(textEl);
|
|
fo.parentNode.replaceChild(g, fo);
|
|
});
|
|
|
|
// Inline styles
|
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
style.textContent = `
|
|
text { font-family: sans-serif; font-size: 14px; fill: ${currentTheme === 'dark' ? '#ffffff' : '#000000'}; }
|
|
.markmap-link { fill: none; stroke: ${currentTheme === 'dark' ? '#cbd5e1' : '#546e7a'}; stroke-width: 2; }
|
|
.markmap-node circle { stroke: ${currentTheme === 'dark' ? '#94a3b8' : '#94a3b8'}; stroke-width: 2; }
|
|
`;
|
|
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
|
|
|
// 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', currentTheme === 'dark' ? '#1f2937' : '#ffffff');
|
|
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
|
|
|
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
|
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
|
const dataUrl = 'data:image/svg+xml;base64,' + svgBase64;
|
|
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
const scale = 9;
|
|
canvas.width = width * scale;
|
|
canvas.height = height * scale;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(scale, scale);
|
|
ctx.fillStyle = currentTheme === 'dark' ? '#1f2937' : '#ffffff';
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
canvas.toBlob((blob) => {
|
|
if (!blob) {
|
|
btn.querySelector('.btn-text').textContent = originalText;
|
|
btn.disabled = false;
|
|
showFeedback(btn, '失败', '失败');
|
|
return;
|
|
}
|
|
|
|
// Use non-bubbling MouseEvent to avoid router interception
|
|
const a = document.createElement('a');
|
|
a.download = '思维导图.png';
|
|
a.href = URL.createObjectURL(blob);
|
|
a.style.display = 'none';
|
|
document.body.appendChild(a);
|
|
|
|
const evt = new MouseEvent('click', {
|
|
view: window,
|
|
bubbles: false,
|
|
cancelable: false
|
|
});
|
|
a.dispatchEvent(evt);
|
|
|
|
setTimeout(() => {
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(a.href);
|
|
}, 100);
|
|
|
|
btn.querySelector('.btn-text').textContent = originalText;
|
|
btn.disabled = false;
|
|
showFeedback(btn);
|
|
}, 'image/png');
|
|
};
|
|
|
|
img.onerror = (e) => {
|
|
console.error('PNG image load error:', e);
|
|
btn.querySelector('.btn-text').textContent = originalText;
|
|
btn.disabled = false;
|
|
showFeedback(btn, '失败', '失败');
|
|
};
|
|
|
|
img.src = dataUrl;
|
|
} catch (err) {
|
|
console.error('PNG export error:', err);
|
|
btn.querySelector('.btn-text').textContent = originalText;
|
|
btn.disabled = false;
|
|
showFeedback(btn, '失败', '失败');
|
|
}
|
|
};
|
|
|
|
const handleZoom = (direction) => {
|
|
if (direction === 'reset') {
|
|
markmapInstance.fit();
|
|
return;
|
|
}
|
|
// Simple zoom simulation if d3 zoom instance is not accessible
|
|
// Markmap uses d3-zoom, so we can try to select the svg and transition
|
|
const svg = d3.select(svgEl);
|
|
// We can't easily access the internal zoom behavior object created by markmap
|
|
// So we rely on fit() for reset, and maybe just let user scroll/pinch for zoom
|
|
// Or we can try to rescale if supported
|
|
if (markmapInstance.rescale) {
|
|
const scale = direction === 'in' ? 1.25 : 0.8;
|
|
markmapInstance.rescale(scale);
|
|
} else {
|
|
// Fallback: just fit, as manual transform manipulation conflicts with d3
|
|
// Or we could try to find the zoom behavior attached to the node
|
|
// const zoom = d3.zoomTransform(svgEl);
|
|
// But we need the zoom behavior function to call scaleBy
|
|
}
|
|
};
|
|
|
|
const handleDepthChange = (e) => {
|
|
const level = parseInt(e.target.value, 10);
|
|
const expandLevel = level === 0 ? Infinity : level;
|
|
|
|
// Deep clone root to reset internal state (payload.fold) added by markmap
|
|
const cleanRoot = JSON.parse(JSON.stringify(root));
|
|
|
|
markmapInstance.setOptions({ initialExpandLevel: expandLevel });
|
|
markmapInstance.setData(cleanRoot);
|
|
markmapInstance.fit();
|
|
};
|
|
|
|
const handleFullscreen = () => {
|
|
const el = containerEl;
|
|
if (!document.fullscreenElement) {
|
|
el.requestFullscreen().then(() => {
|
|
setTimeout(() => markmapInstance.fit(), 200);
|
|
});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
if (document.fullscreenElement === containerEl) {
|
|
setTimeout(() => markmapInstance.fit(), 200);
|
|
}
|
|
});
|
|
|
|
const handleThemeToggle = () => {
|
|
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
setTheme(wrapper, currentTheme);
|
|
};
|
|
|
|
downloadSvgBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleDownloadSVG(); });
|
|
downloadMdBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleDownloadMD(); });
|
|
downloadPngBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleDownloadPNG(); });
|
|
zoomInBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleZoom('in'); });
|
|
zoomOutBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleZoom('out'); });
|
|
zoomResetBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleZoom('reset'); });
|
|
depthSelect?.addEventListener('change', (e) => { e.stopPropagation(); handleDepthChange(e); });
|
|
fullscreenBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleFullscreen(); });
|
|
themeToggleBtn?.addEventListener('click', (e) => { e.stopPropagation(); handleThemeToggle(); });
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', renderMindmap);
|
|
} else {
|
|
renderMindmap();
|
|
}
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
|
|
class Action:
|
|
class Valves(BaseModel):
|
|
SHOW_STATUS: bool = Field(
|
|
default=True, description="是否在聊天界面显示操作状态更新。"
|
|
)
|
|
MODEL_ID: str = Field(
|
|
default="",
|
|
description="用于文本分析的内置LLM模型ID。如果为空,则使用当前对话的模型。",
|
|
)
|
|
MIN_TEXT_LENGTH: int = Field(
|
|
default=100,
|
|
description="进行思维导图分析所需的最小文本长度(字符数)。",
|
|
)
|
|
CLEAR_PREVIOUS_HTML: bool = Field(
|
|
default=False,
|
|
description="是否强制清除旧的插件结果(如果为 True,则不合并,直接覆盖)。",
|
|
)
|
|
MESSAGE_COUNT: int = Field(
|
|
default=1,
|
|
description="用于生成的最近消息数量。设置为1仅使用最后一条消息,更大值可包含更多上下文。",
|
|
)
|
|
OUTPUT_MODE: str = Field(
|
|
default="html",
|
|
description="输出模式: 'html' 为交互式HTML(默认),'image' 为嵌入Markdown图片。",
|
|
)
|
|
|
|
def __init__(self):
|
|
self.valves = self.Valves()
|
|
self.weekday_map = {
|
|
"Monday": "星期一",
|
|
"Tuesday": "星期二",
|
|
"Wednesday": "星期三",
|
|
"Thursday": "星期四",
|
|
"Friday": "星期五",
|
|
"Saturday": "星期六",
|
|
"Sunday": "星期日",
|
|
}
|
|
|
|
def _get_user_context(self, __user__: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|
"""Extract basic user context with safe fallbacks."""
|
|
if isinstance(__user__, (list, tuple)):
|
|
user_data = __user__[0] if __user__ else {}
|
|
elif isinstance(__user__, dict):
|
|
user_data = __user__
|
|
else:
|
|
user_data = {}
|
|
|
|
return {
|
|
"user_id": user_data.get("id", "unknown_user"),
|
|
"user_name": user_data.get("name", "用户"),
|
|
"user_language": user_data.get("language", "zh-CN"),
|
|
}
|
|
|
|
def _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格式,将整个输出作为摘要处理。")
|
|
extracted_content = llm_output.strip()
|
|
return extracted_content.replace("</script>", "<\\/script>")
|
|
|
|
async def _emit_status(self, emitter, description: str, done: bool = False):
|
|
"""发送状态更新事件。"""
|
|
if self.valves.SHOW_STATUS and emitter:
|
|
await emitter(
|
|
{"type": "status", "data": {"description": description, "done": done}}
|
|
)
|
|
|
|
async def _emit_notification(self, emitter, content: str, ntype: str = "info"):
|
|
"""发送通知事件 (info/success/warning/error)。"""
|
|
if emitter:
|
|
await emitter(
|
|
{"type": "notification", "data": {"type": ntype, "content": content}}
|
|
)
|
|
|
|
def _remove_existing_html(self, content: str) -> str:
|
|
"""移除内容中已有的插件生成 HTML 代码块 (通过标记识别)。"""
|
|
pattern = r"```html\s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?```"
|
|
return re.sub(pattern, "", content).strip()
|
|
|
|
def _extract_text_content(self, content) -> str:
|
|
"""从消息内容中提取文本,支持多模态消息格式"""
|
|
if isinstance(content, str):
|
|
return content
|
|
elif isinstance(content, list):
|
|
# 多模态消息: [{"type": "text", "text": "..."}, {"type": "image_url", ...}]
|
|
text_parts = []
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
text_parts.append(item.get("text", ""))
|
|
elif isinstance(item, str):
|
|
text_parts.append(item)
|
|
return "\n".join(text_parts)
|
|
return str(content) if content else ""
|
|
|
|
def _merge_html(
|
|
self,
|
|
existing_html_code: str,
|
|
new_content: str,
|
|
new_styles: str = "",
|
|
new_scripts: str = "",
|
|
user_language: str = "zh-CN",
|
|
) -> str:
|
|
"""
|
|
将新内容合并到现有的 HTML 容器中,或者创建一个新的容器。
|
|
"""
|
|
if (
|
|
"<!-- OPENWEBUI_PLUGIN_OUTPUT -->" in existing_html_code
|
|
and "<!-- CONTENT_INSERTION_POINT -->" in existing_html_code
|
|
):
|
|
base_html = existing_html_code
|
|
base_html = re.sub(r"^```html\s*", "", base_html)
|
|
base_html = re.sub(r"\s*```$", "", base_html)
|
|
else:
|
|
base_html = HTML_WRAPPER_TEMPLATE.replace("{user_language}", user_language)
|
|
|
|
wrapped_content = f'<div class="plugin-item">\n{new_content}\n</div>'
|
|
|
|
if new_styles:
|
|
base_html = base_html.replace(
|
|
"/* STYLES_INSERTION_POINT */",
|
|
f"{new_styles}\n/* STYLES_INSERTION_POINT */",
|
|
)
|
|
|
|
base_html = base_html.replace(
|
|
"<!-- CONTENT_INSERTION_POINT -->",
|
|
f"{wrapped_content}\n<!-- CONTENT_INSERTION_POINT -->",
|
|
)
|
|
|
|
if new_scripts:
|
|
base_html = base_html.replace(
|
|
"<!-- SCRIPTS_INSERTION_POINT -->",
|
|
f"{new_scripts}\n<!-- SCRIPTS_INSERTION_POINT -->",
|
|
)
|
|
|
|
return base_html.strip()
|
|
|
|
def _generate_image_js_code(
|
|
self,
|
|
unique_id: str,
|
|
chat_id: str,
|
|
message_id: str,
|
|
markdown_syntax: str,
|
|
) -> str:
|
|
"""生成用于前端 SVG 渲染和图片嵌入的 JavaScript 代码"""
|
|
|
|
# 转义语法以便嵌入 JS
|
|
syntax_escaped = (
|
|
markdown_syntax.replace("\\", "\\\\")
|
|
.replace("`", "\\`")
|
|
.replace("${", "\\${")
|
|
.replace("</script>", "<\\/script>")
|
|
)
|
|
|
|
return f"""
|
|
(async function() {{
|
|
const uniqueId = "{unique_id}";
|
|
const chatId = "{chat_id}";
|
|
const messageId = "{message_id}";
|
|
const defaultWidth = 1200;
|
|
const defaultHeight = 800;
|
|
|
|
// 主题检测 - 检查 OpenWebUI 当前主题
|
|
const detectTheme = () => {{
|
|
try {{
|
|
// 1. 检查 html/body 的 class 或 data-theme 属性
|
|
const html = document.documentElement;
|
|
const body = document.body;
|
|
const htmlClass = html ? html.className : '';
|
|
const bodyClass = body ? body.className : '';
|
|
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
|
|
|
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
|
|
return 'dark';
|
|
}}
|
|
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
|
|
return 'light';
|
|
}}
|
|
|
|
// 2. 检查 meta theme-color
|
|
const metas = document.querySelectorAll('meta[name="theme-color"]');
|
|
if (metas.length > 0) {{
|
|
const color = metas[metas.length - 1].content.trim();
|
|
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
|
|
if (m) {{
|
|
const hex = m[1];
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
return luma < 0.5 ? 'dark' : 'light';
|
|
}}
|
|
}}
|
|
|
|
// 3. 检查系统偏好
|
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
|
|
return 'dark';
|
|
}}
|
|
|
|
return 'light';
|
|
}} catch (e) {{
|
|
return 'light';
|
|
}}
|
|
}};
|
|
|
|
const currentTheme = detectTheme();
|
|
console.log("[思维导图图片] 检测到主题:", currentTheme);
|
|
|
|
// 基于主题的颜色配置
|
|
const colors = currentTheme === 'dark' ? {{
|
|
background: '#1f2937',
|
|
text: '#e5e7eb',
|
|
link: '#94a3b8',
|
|
nodeStroke: '#64748b'
|
|
}} : {{
|
|
background: '#ffffff',
|
|
text: '#1f2937',
|
|
link: '#546e7a',
|
|
nodeStroke: '#94a3b8'
|
|
}};
|
|
|
|
// 自动检测聊天容器宽度以实现自适应
|
|
let svgWidth = defaultWidth;
|
|
let svgHeight = defaultHeight;
|
|
const chatContainer = document.getElementById('chat-container');
|
|
if (chatContainer) {{
|
|
const containerWidth = chatContainer.clientWidth;
|
|
if (containerWidth > 100) {{
|
|
// 使用容器宽度的90%(留出边距)
|
|
svgWidth = Math.floor(containerWidth * 0.9);
|
|
// 根据默认尺寸保持宽高比
|
|
svgHeight = Math.floor(svgWidth * (defaultHeight / defaultWidth));
|
|
console.log("[思维导图图片] 自动检测容器宽度:", containerWidth, "-> SVG:", svgWidth, "x", svgHeight);
|
|
}}
|
|
}}
|
|
|
|
console.log("[思维导图图片] 开始渲染...");
|
|
console.log("[思维导图图片] chatId:", chatId, "messageId:", messageId);
|
|
|
|
try {{
|
|
// 加载 D3
|
|
if (typeof d3 === 'undefined') {{
|
|
console.log("[思维导图图片] 正在加载 D3...");
|
|
await new Promise((resolve, reject) => {{
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/d3@7';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
}});
|
|
}}
|
|
|
|
// 加载 markmap-lib
|
|
if (!window.markmap || !window.markmap.Transformer) {{
|
|
console.log("[思维导图图片] 正在加载 markmap-lib...");
|
|
await new Promise((resolve, reject) => {{
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/markmap-lib@0.17';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
}});
|
|
}}
|
|
|
|
// 加载 markmap-view
|
|
if (!window.markmap || !window.markmap.Markmap) {{
|
|
console.log("[思维导图图片] 正在加载 markmap-view...");
|
|
await new Promise((resolve, reject) => {{
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/markmap-view@0.17';
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
}});
|
|
}}
|
|
|
|
const {{ Transformer, Markmap }} = window.markmap;
|
|
|
|
// 获取 markdown 语法
|
|
let syntaxContent = `{syntax_escaped}`;
|
|
console.log("[思维导图图片] 语法长度:", syntaxContent.length);
|
|
|
|
// 创建离屏容器
|
|
const container = document.createElement('div');
|
|
container.id = 'mindmap-offscreen-' + uniqueId;
|
|
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:' + svgHeight + 'px;';
|
|
document.body.appendChild(container);
|
|
|
|
// 创建 SVG 元素
|
|
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svgEl.setAttribute('width', svgWidth);
|
|
svgEl.setAttribute('height', svgHeight);
|
|
svgEl.style.width = svgWidth + 'px';
|
|
svgEl.style.height = svgHeight + 'px';
|
|
svgEl.style.backgroundColor = colors.background;
|
|
container.appendChild(svgEl);
|
|
|
|
// 将 markdown 转换为树结构
|
|
const transformer = new Transformer();
|
|
const {{ root }} = transformer.transform(syntaxContent);
|
|
|
|
// 创建 markmap 实例
|
|
const options = {{
|
|
autoFit: true,
|
|
initialExpandLevel: Infinity,
|
|
zoom: false,
|
|
pan: false
|
|
}};
|
|
|
|
console.log("[思维导图图片] 正在渲染 markmap...");
|
|
const markmapInstance = Markmap.create(svgEl, options, root);
|
|
|
|
// 等待渲染完成
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
markmapInstance.fit();
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
// 克隆并准备 SVG 导出
|
|
const clonedSvg = svgEl.cloneNode(true);
|
|
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
|
|
|
// 添加背景矩形(使用主题颜色)
|
|
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
bgRect.setAttribute('width', '100%');
|
|
bgRect.setAttribute('height', '100%');
|
|
bgRect.setAttribute('fill', colors.background);
|
|
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
|
|
|
// 添加内联样式(使用主题颜色)
|
|
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
style.textContent = `
|
|
text {{ font-family: sans-serif; font-size: 14px; fill: ${{colors.text}}; }}
|
|
foreignObject, .markmap-foreign, .markmap-foreign div {{ color: ${{colors.text}}; font-family: sans-serif; font-size: 14px; }}
|
|
h1 {{ font-size: 22px; font-weight: 700; margin: 0; }}
|
|
h2 {{ font-size: 18px; font-weight: 600; margin: 0; }}
|
|
strong {{ font-weight: 700; }}
|
|
.markmap-link {{ stroke: ${{colors.link}}; fill: none; }}
|
|
.markmap-node circle, .markmap-node rect {{ stroke: ${{colors.nodeStroke}}; }}
|
|
`;
|
|
clonedSvg.insertBefore(style, bgRect.nextSibling);
|
|
|
|
// 将 foreignObject 转换为 text 以提高兼容性
|
|
const foreignObjects = clonedSvg.querySelectorAll('foreignObject');
|
|
foreignObjects.forEach(fo => {{
|
|
const text = fo.textContent || '';
|
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
textEl.setAttribute('x', fo.getAttribute('x') || '0');
|
|
textEl.setAttribute('y', (parseFloat(fo.getAttribute('y') || '0') + 14).toString());
|
|
textEl.setAttribute('fill', colors.text);
|
|
textEl.setAttribute('font-family', 'sans-serif');
|
|
textEl.setAttribute('font-size', '14');
|
|
textEl.textContent = text.trim();
|
|
g.appendChild(textEl);
|
|
fo.parentNode.replaceChild(g, fo);
|
|
}});
|
|
|
|
// 序列化 SVG 为字符串
|
|
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
|
|
|
// 清理容器
|
|
document.body.removeChild(container);
|
|
|
|
// 将 SVG 字符串转换为 Blob
|
|
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
|
|
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
|
|
|
|
// 上传文件到 OpenWebUI API
|
|
console.log("[思维导图图片] 正在上传 SVG 文件...");
|
|
const token = localStorage.getItem("token");
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const uploadResponse = await fetch('/api/v1/files/', {{
|
|
method: 'POST',
|
|
headers: {{
|
|
'Authorization': `Bearer ${{token}}`
|
|
}},
|
|
body: formData
|
|
}});
|
|
|
|
if (!uploadResponse.ok) {{
|
|
throw new Error(`上传失败: ${{uploadResponse.statusText}}`);
|
|
}}
|
|
|
|
const fileData = await uploadResponse.json();
|
|
const fileId = fileData.id;
|
|
const imageUrl = `/api/v1/files/${{fileId}}/content`;
|
|
|
|
console.log("[思维导图图片] 文件已上传, ID:", fileId);
|
|
|
|
// 生成包含文件 URL 的 markdown 图片
|
|
const markdownImage = ``;
|
|
|
|
// 通过 API 更新消息
|
|
if (chatId && messageId) {{
|
|
const token = localStorage.getItem("token");
|
|
|
|
// 带重试逻辑的请求函数
|
|
const fetchWithRetry = async (url, options, retries = 3) => {{
|
|
for (let i = 0; i < retries; i++) {{
|
|
try {{
|
|
const response = await fetch(url, options);
|
|
if (response.ok) return response;
|
|
if (i < retries - 1) {{
|
|
console.log(`[思维导图图片] 重试 ${{i + 1}}/${{retries}}: ${{url}}`);
|
|
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
|
}}
|
|
}} catch (e) {{
|
|
if (i === retries - 1) throw e;
|
|
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
|
}}
|
|
}}
|
|
return null;
|
|
}};
|
|
|
|
// 获取当前聊天数据
|
|
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
|
method: "GET",
|
|
headers: {{ "Authorization": `Bearer ${{token}}` }}
|
|
}});
|
|
|
|
if (!getResponse.ok) {{
|
|
throw new Error("获取聊天数据失败: " + getResponse.status);
|
|
}}
|
|
|
|
const chatData = await getResponse.json();
|
|
let updatedMessages = [];
|
|
let newContent = "";
|
|
|
|
if (chatData.chat && chatData.chat.messages) {{
|
|
updatedMessages = chatData.chat.messages.map(m => {{
|
|
if (m.id === messageId) {{
|
|
const originalContent = m.content || "";
|
|
// 移除已有的思维导图图片 (包括 base64 和文件 URL 格式)
|
|
const mindmapPattern = /\\n*!\\[🧠[^\\]]*\\]\\((?:data:image\\/[^)]+|(?:\\/api\\/v1\\/files\\/[^)]+))\\)/g;
|
|
let cleanedContent = originalContent.replace(mindmapPattern, "");
|
|
cleanedContent = cleanedContent.replace(/\\n{{3,}}/g, "\\n\\n").trim();
|
|
// 追加新图片
|
|
newContent = cleanedContent + "\\n\\n" + markdownImage;
|
|
|
|
// 关键: 同时更新 messages 数组和 history 对象中的内容
|
|
// history 对象是数据库的单一真值来源
|
|
if (chatData.chat.history && chatData.chat.history.messages) {{
|
|
if (chatData.chat.history.messages[messageId]) {{
|
|
chatData.chat.history.messages[messageId].content = newContent;
|
|
}}
|
|
}}
|
|
|
|
return {{ ...m, content: newContent }};
|
|
}}
|
|
return m;
|
|
}});
|
|
}}
|
|
|
|
if (!newContent) {{
|
|
console.warn("[思维导图图片] 找不到要更新的消息");
|
|
return;
|
|
}}
|
|
|
|
// 尝试通过事件 API 更新前端显示(可选,部分版本可能不支持)
|
|
try {{
|
|
await fetch(`/api/v1/chats/${{chatId}}/messages/${{messageId}}/event`, {{
|
|
method: "POST",
|
|
headers: {{
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${{token}}`
|
|
}},
|
|
body: JSON.stringify({{
|
|
type: "chat:message",
|
|
data: {{ content: newContent }}
|
|
}})
|
|
}});
|
|
}} catch (eventErr) {{
|
|
// 事件 API 是可选的,继续执行持久化
|
|
console.log("[思维导图图片] 事件 API 不可用,继续执行...");
|
|
}}
|
|
|
|
// 通过更新整个聊天对象来持久化到数据库
|
|
// 遵循 OpenWebUI 后端控制的 API 流程
|
|
const updatePayload = {{
|
|
chat: {{
|
|
...chatData.chat,
|
|
messages: updatedMessages
|
|
// history 已在上面原地更新
|
|
}}
|
|
}};
|
|
|
|
const persistResponse = await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
|
|
method: "POST",
|
|
headers: {{
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${{token}}`
|
|
}},
|
|
body: JSON.stringify(updatePayload)
|
|
}});
|
|
|
|
if (persistResponse && persistResponse.ok) {{
|
|
console.log("[思维导图图片] ✅ 消息已持久化保存!");
|
|
}} else {{
|
|
console.error("[思维导图图片] ❌ 重试后仍然无法持久化消息");
|
|
}}
|
|
}} else {{
|
|
console.warn("[思维导图图片] ⚠️ 缺少 chatId 或 messageId,无法持久化");
|
|
}}
|
|
|
|
}} catch (error) {{
|
|
console.error("[思维导图图片] 错误:", error);
|
|
}}
|
|
}})();
|
|
"""
|
|
|
|
async def action(
|
|
self,
|
|
body: dict,
|
|
__user__: Optional[Dict[str, Any]] = None,
|
|
__event_emitter__: Optional[Any] = None,
|
|
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
|
__metadata__: Optional[dict] = None,
|
|
__request__: Optional[Request] = None,
|
|
) -> Optional[dict]:
|
|
logger.info("Action: 思维导图 (v0.9.1) started")
|
|
user_ctx = self._get_user_context(__user__)
|
|
user_language = user_ctx["user_language"]
|
|
user_name = user_ctx["user_name"]
|
|
user_id = user_ctx["user_id"]
|
|
|
|
try:
|
|
tz_env = os.environ.get("TZ")
|
|
tzinfo = ZoneInfo(tz_env) if tz_env else None
|
|
now_dt = datetime.now(tzinfo or timezone.utc)
|
|
current_date_time_str = now_dt.strftime("%Y年%m月%d日 %H:%M:%S")
|
|
current_weekday_en = now_dt.strftime("%A")
|
|
current_weekday_zh = self.weekday_map.get(current_weekday_en, "未知星期")
|
|
current_year = now_dt.strftime("%Y")
|
|
current_timezone_str = tz_env or "UTC"
|
|
except Exception as e:
|
|
logger.warning(f"获取时区信息失败: {e},使用默认值。")
|
|
now = datetime.now()
|
|
current_date_time_str = now.strftime("%Y年%m月%d日 %H:%M:%S")
|
|
current_weekday_zh = "未知星期"
|
|
current_year = now.strftime("%Y")
|
|
current_timezone_str = "未知时区"
|
|
|
|
await self._emit_notification(
|
|
__event_emitter__, "思维导图已启动,正在为您生成思维导图...", "info"
|
|
)
|
|
|
|
messages = body.get("messages")
|
|
if not messages or not isinstance(messages, list):
|
|
error_message = "无法获取有效的用户消息内容。"
|
|
await self._emit_notification(__event_emitter__, error_message, "error")
|
|
return {
|
|
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
|
|
}
|
|
|
|
# Get last N messages based on MESSAGE_COUNT
|
|
message_count = min(self.valves.MESSAGE_COUNT, len(messages))
|
|
recent_messages = messages[-message_count:]
|
|
|
|
# Aggregate content from selected messages with labels
|
|
aggregated_parts = []
|
|
for i, msg in enumerate(recent_messages, 1):
|
|
text_content = self._extract_text_content(msg.get("content"))
|
|
if text_content:
|
|
role = msg.get("role", "unknown")
|
|
role_label = (
|
|
"用户"
|
|
if role == "user"
|
|
else "助手" if role == "assistant" else role
|
|
)
|
|
aggregated_parts.append(f"{text_content}")
|
|
|
|
if not aggregated_parts:
|
|
error_message = "无法获取有效的用户消息内容。"
|
|
await self._emit_notification(__event_emitter__, error_message, "error")
|
|
return {
|
|
"messages": [{"role": "assistant", "content": f"❌ {error_message}"}]
|
|
}
|
|
|
|
original_content = "\n\n---\n\n".join(aggregated_parts)
|
|
|
|
parts = re.split(r"```html.*?```", original_content, flags=re.DOTALL)
|
|
long_text_content = ""
|
|
if parts:
|
|
for part in reversed(parts):
|
|
if part.strip():
|
|
long_text_content = part.strip()
|
|
break
|
|
|
|
if not long_text_content:
|
|
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}字符的文本。"
|
|
await self._emit_notification(
|
|
__event_emitter__, short_text_message, "warning"
|
|
)
|
|
return {
|
|
"messages": [
|
|
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
|
]
|
|
}
|
|
|
|
await self._emit_status(
|
|
__event_emitter__, "思维导图: 深入分析文本结构...", False
|
|
)
|
|
|
|
try:
|
|
unique_id = f"id_{int(time.time() * 1000)}"
|
|
|
|
formatted_user_prompt = USER_PROMPT_GENERATE_MINDMAP.format(
|
|
user_name=user_name,
|
|
current_date_time_str=current_date_time_str,
|
|
current_weekday=current_weekday_zh,
|
|
current_timezone_str=current_timezone_str,
|
|
user_language=user_language,
|
|
long_text_content=long_text_content,
|
|
)
|
|
|
|
# 确定使用的模型
|
|
target_model = self.valves.MODEL_ID
|
|
if not target_model:
|
|
target_model = body.get("model")
|
|
|
|
llm_payload = {
|
|
"model": target_model,
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM_PROMPT_MINDMAP_ASSISTANT},
|
|
{"role": "user", "content": formatted_user_prompt},
|
|
],
|
|
"stream": False,
|
|
}
|
|
user_obj = Users.get_user_by_id(user_id)
|
|
if not user_obj:
|
|
raise ValueError(f"无法获取用户对象,用户ID: {user_id}")
|
|
|
|
llm_response = await generate_chat_completion(
|
|
__request__, llm_payload, user_obj
|
|
)
|
|
|
|
if (
|
|
not llm_response
|
|
or "choices" not in llm_response
|
|
or not llm_response["choices"]
|
|
):
|
|
raise ValueError("LLM响应格式不正确或为空。")
|
|
|
|
assistant_response_content = llm_response["choices"][0]["message"][
|
|
"content"
|
|
]
|
|
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
|
|
|
# Prepare content components
|
|
content_html = (
|
|
CONTENT_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
|
.replace("{user_name}", user_name)
|
|
.replace("{current_date_time_str}", current_date_time_str)
|
|
.replace("{current_year}", current_year)
|
|
.replace("{markdown_syntax}", markdown_syntax)
|
|
)
|
|
|
|
script_html = SCRIPT_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
|
|
|
# Extract existing HTML if any
|
|
existing_html_block = ""
|
|
match = re.search(
|
|
r"```html\s*(<!-- OPENWEBUI_PLUGIN_OUTPUT -->[\s\S]*?)```",
|
|
long_text_content,
|
|
)
|
|
if match:
|
|
existing_html_block = match.group(1)
|
|
|
|
if self.valves.CLEAR_PREVIOUS_HTML:
|
|
long_text_content = self._remove_existing_html(long_text_content)
|
|
final_html = self._merge_html(
|
|
"", content_html, CSS_TEMPLATE_MINDMAP, script_html, user_language
|
|
)
|
|
else:
|
|
# If we found existing HTML, we remove the old block from text and merge into it
|
|
if existing_html_block:
|
|
long_text_content = self._remove_existing_html(long_text_content)
|
|
final_html = self._merge_html(
|
|
existing_html_block,
|
|
content_html,
|
|
CSS_TEMPLATE_MINDMAP,
|
|
script_html,
|
|
user_language,
|
|
)
|
|
else:
|
|
final_html = self._merge_html(
|
|
"",
|
|
content_html,
|
|
CSS_TEMPLATE_MINDMAP,
|
|
script_html,
|
|
user_language,
|
|
)
|
|
|
|
# 检查输出模式
|
|
if self.valves.OUTPUT_MODE == "image":
|
|
# 图片模式: 使用 JavaScript 渲染并嵌入为 Markdown 图片
|
|
chat_id = self._extract_chat_id(body, __metadata__)
|
|
message_id = self._extract_message_id(body, __metadata__)
|
|
|
|
await self._emit_status(
|
|
__event_emitter__,
|
|
"思维导图: 正在渲染图片...",
|
|
False,
|
|
)
|
|
|
|
if __event_call__:
|
|
js_code = self._generate_image_js_code(
|
|
unique_id=unique_id,
|
|
chat_id=chat_id,
|
|
message_id=message_id,
|
|
markdown_syntax=markdown_syntax,
|
|
)
|
|
|
|
await __event_call__(
|
|
{
|
|
"type": "execute",
|
|
"data": {"code": js_code},
|
|
}
|
|
)
|
|
|
|
await self._emit_status(
|
|
__event_emitter__, "思维导图: 图片已生成!", True
|
|
)
|
|
await self._emit_notification(
|
|
__event_emitter__,
|
|
f"思维导图图片已生成,{user_name}!",
|
|
"success",
|
|
)
|
|
logger.info("Action: 思维导图 (v0.9.1) 图片模式完成")
|
|
return body
|
|
|
|
# HTML 模式(默认): 嵌入为 HTML 块
|
|
html_embed_tag = f"```html\n{final_html}\n```"
|
|
body["messages"][-1]["content"] = f"{long_text_content}\n\n{html_embed_tag}"
|
|
|
|
await self._emit_status(__event_emitter__, "思维导图: 绘制完成!", True)
|
|
await self._emit_notification(
|
|
__event_emitter__, f"思维导图已生成,{user_name}!", "success"
|
|
)
|
|
logger.info("Action: 思维导图 (v0.9.1) HTML 模式完成")
|
|
|
|
except Exception as e:
|
|
error_message = f"思维导图处理失败: {str(e)}"
|
|
logger.error(f"思维导图错误: {error_message}", exc_info=True)
|
|
user_facing_error = f"抱歉,思维导图在处理时遇到错误: {str(e)}。\n请检查Open WebUI后端日志获取更多详情。"
|
|
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"
|
|
)
|
|
|
|
return body
|