2025-12-20 12:34:49 +08:00
"""
title : Smart Mind Map
2025-12-31 02:00:10 +08:00
author : Fu - Jie
author_url : https : / / github . com / Fu - Jie
funding_url : https : / / github . com / Fu - Jie / awesome - openwebui
2026-01-06 19:26:43 +08:00
version : 0.9 .0
2025-12-30 02:28:46 +00:00
icon_url : data : image / svg + xml ; base64 , PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8 + PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4 =
2025-12-31 02:00:10 +08:00
description : Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge .
2025-12-20 12:34:49 +08:00
"""
import logging
2025-12-31 02:00:10 +08:00
import os
2025-12-20 12:34:49 +08:00
import re
2025-12-31 02:00:10 +08:00
import time
from datetime import datetime , timezone
2026-01-06 19:26:43 +08:00
from typing import Any , Callable , Awaitable , Dict , Optional
2025-12-31 02:00:10 +08:00
from zoneinfo import ZoneInfo
2025-12-20 12:34:49 +08:00
from fastapi import Request
2025-12-31 02:00:10 +08:00
from pydantic import BaseModel , Field
2025-12-20 12:34:49 +08:00
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 = """
You are a professional mind map generation assistant , capable of efficiently analyzing long - form text provided by users and structuring its core themes , key concepts , branches , and sub - branches into standard Markdown list syntax for rendering by Markmap . js .
Please strictly follow these guidelines :
- * * Language * * : All output must be in the language specified by the user .
- * * Format * * : Your output must strictly be in Markdown list format , wrapped with ` ` ` markdown and ` ` ` .
- Use ` #` to define the central theme (root node).
- Use ` - ` with two - space indentation to represent branches and sub - branches .
- * * Content * * :
- Identify the central theme of the text as the ` #` heading.
- Identify main concepts as first - level list items .
- Identify supporting details or sub - concepts as nested list items .
- Node content should be concise and clear , avoiding verbosity .
- * * Output Markdown syntax only * * : Do not include any additional greetings , explanations , or guiding text .
- * * If text is too short or cannot generate a valid mind map * * : Output a simple Markdown list indicating inability to generate , for example :
` ` ` markdown
# Unable to Generate Mind Map
- Reason : Insufficient or unclear text content
` ` `
"""
USER_PROMPT_GENERATE_MINDMAP = """
Please analyze the following long - form text and structure its core themes , key concepts , branches , and sub - branches into standard Markdown list syntax for Markmap . js rendering .
- - -
* * User Context Information : * *
User Name : { user_name }
Current Date & Time : { current_date_time_str }
Current Weekday : { current_weekday }
Current Timezone : { current_timezone_str }
User Language : { user_language }
- - -
* * Long - form Text Content : * *
{ long_text_content }
"""
2025-12-20 15:43:58 +08:00
HTML_WRAPPER_TEMPLATE = """
2025-12-20 15:07:41 +08:00
< ! - - OPENWEBUI_PLUGIN_OUTPUT - - >
2025-12-20 12:34:49 +08:00
< ! DOCTYPE html >
< html lang = " {user_language} " >
< head >
< meta charset = " UTF-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1.0 " >
< style >
2025-12-20 15:43:58 +08:00
body {
font - family : - apple - system , BlinkMacSystemFont , " Segoe UI " , Roboto , Helvetica , Arial , sans - serif ;
margin : 0 ;
padding : 10 px ;
background - color : transparent ;
}
#main-container {
display : flex ;
2025-12-31 02:00:10 +08:00
flex - direction : column ;
2025-12-20 15:43:58 +08:00
gap : 20 px ;
2025-12-31 02:00:10 +08:00
align - items : stretch ;
2025-12-20 15:43:58 +08:00
width : 100 % ;
}
. plugin - item {
2025-12-31 02:00:10 +08:00
width : 100 % ;
2025-12-20 15:43:58 +08:00
border - radius : 12 px ;
2025-12-31 02:00:10 +08:00
overflow : visible ;
2025-12-20 15:43:58 +08:00
transition : all 0.3 s ease ;
}
. plugin - item : hover {
2025-12-28 20:08:50 +08:00
transform : translateY ( - 2 px ) ;
2025-12-20 15:43:58 +08:00
}
/ * STYLES_INSERTION_POINT * /
< / style >
< / head >
< body >
< div id = " main-container " >
< ! - - CONTENT_INSERTION_POINT - - >
< / div >
< ! - - SCRIPTS_INSERTION_POINT - - >
< / body >
< / html >
"""
CSS_TEMPLATE_MINDMAP = """
2025-12-20 12:34:49 +08:00
: root {
- - primary - color : #1e88e5;
- - secondary - color : #43a047;
- - background - color : #f4f6f8;
- - card - bg - color : #ffffff;
2025-12-31 02:00:10 +08:00
- - text - color : #000000;
- - link - color : #546e7a;
- - node - stroke - color : #90a4ae;
2025-12-20 12:34:49 +08:00
- - muted - text - color : #546e7a;
- - border - color : #e0e0e0;
- - header - gradient : linear - gradient ( 135 deg , var ( - - secondary - color ) , var ( - - primary - color ) ) ;
2025-12-31 02:00:10 +08:00
- - shadow : 0 10 px 20 px rgba ( 0 , 0 , 0 , 0.06 ) ;
2025-12-20 12:34:49 +08:00
- - border - radius : 12 px ;
- - font - family : - apple - system , BlinkMacSystemFont , " Segoe UI " , Roboto , " Helvetica Neue " , Arial , sans - serif ;
}
2025-12-31 02:00:10 +08:00
. 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 ( 135 deg , #0ea5e9, #22c55e);
- - shadow : 0 10 px 20 px rgba ( 0 , 0 , 0 , 0.3 ) ;
}
2025-12-20 15:43:58 +08:00
. mindmap - container - wrapper {
2025-12-20 12:34:49 +08:00
font - family : var ( - - font - family ) ;
2025-12-31 02:00:10 +08:00
line - height : 1.6 ;
2025-12-20 12:34:49 +08:00
color : var ( - - text - color ) ;
margin : 0 ;
2025-12-20 15:43:58 +08:00
padding : 0 ;
2025-12-20 12:34:49 +08:00
- webkit - font - smoothing : antialiased ;
- moz - osx - font - smoothing : grayscale ;
2025-12-20 15:43:58 +08:00
height : 100 % ;
display : flex ;
flex - direction : column ;
2025-12-31 02:00:10 +08:00
background : var ( - - background - color ) ;
border : 1 px solid var ( - - border - color ) ;
border - radius : var ( - - border - radius ) ;
box - shadow : var ( - - shadow ) ;
2025-12-20 12:34:49 +08:00
}
. header {
background : var ( - - header - gradient ) ;
color : white ;
2025-12-31 02:00:10 +08:00
padding : 18 px 20 px ;
2025-12-20 12:34:49 +08:00
text - align : center ;
2025-12-31 02:00:10 +08:00
border - top - left - radius : var ( - - border - radius ) ;
border - top - right - radius : var ( - - border - radius ) ;
2025-12-20 12:34:49 +08:00
}
. header h1 {
margin : 0 ;
2025-12-31 02:00:10 +08:00
font - size : 1.4 em ;
2025-12-20 12:34:49 +08:00
font - weight : 600 ;
2025-12-31 02:00:10 +08:00
letter - spacing : 0.3 px ;
2025-12-20 12:34:49 +08:00
}
. user - context {
2025-12-31 02:00:10 +08:00
font - size : 0.85 em ;
2025-12-20 12:34:49 +08:00
color : var ( - - muted - text - color ) ;
2025-12-31 02:00:10 +08:00
background - color : rgba ( 255 , 255 , 255 , 0.6 ) ;
padding : 8 px 14 px ;
2025-12-20 12:34:49 +08:00
display : flex ;
2025-12-31 02:00:10 +08:00
justify - content : space - between ;
2025-12-20 12:34:49 +08:00
flex - wrap : wrap ;
2025-12-20 15:43:58 +08:00
border - bottom : 1 px solid var ( - - border - color ) ;
2025-12-31 02:00:10 +08:00
gap : 6 px ;
}
. theme - dark . user - context {
background - color : rgba ( 31 , 41 , 55 , 0.7 ) ;
2025-12-20 12:34:49 +08:00
}
2025-12-31 02:00:10 +08:00
. user - context span { margin : 2 px 6 px ; }
. content - area {
padding : 16 px ;
2025-12-20 15:43:58 +08:00
flex - grow : 1 ;
2025-12-31 02:00:10 +08:00
background : var ( - - card - bg - color ) ;
2025-12-20 12:34:49 +08:00
}
. markmap - container {
position : relative ;
2025-12-31 02:00:10 +08:00
background - color : var ( - - card - bg - color ) ;
border - radius : 10 px ;
padding : 12 px ;
2025-12-20 12:34:49 +08:00
display : flex ;
justify - content : center ;
align - items : center ;
border : 1 px solid var ( - - border - color ) ;
2025-12-31 02:00:10 +08:00
width : 100 % ;
min - height : 60 vh ;
overflow : visible ;
2025-12-20 12:34:49 +08:00
}
2025-12-31 02:00:10 +08:00
. 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 ;
2025-12-20 12:34:49 +08:00
}
2025-12-31 02:00:10 +08:00
. 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 : 10 px ;
justify - content : center ;
margin - top : 12 px ;
}
. btn - group {
display : inline - flex ;
gap : 6 px ;
align - items : center ;
}
. control - btn {
2025-12-20 12:34:49 +08:00
background - color : var ( - - primary - color ) ;
color : white ;
border : none ;
2025-12-31 02:00:10 +08:00
padding : 8 px 12 px ;
border - radius : 8 px ;
2025-12-20 15:43:58 +08:00
font - size : 0.9 em ;
2025-12-20 12:34:49 +08:00
font - weight : 500 ;
cursor : pointer ;
2025-12-31 02:00:10 +08:00
transition : background - color 0.15 s ease , transform 0.15 s ease ;
2025-12-20 12:34:49 +08:00
display : inline - flex ;
align - items : center ;
2025-12-20 15:43:58 +08:00
gap : 6 px ;
2025-12-31 02:00:10 +08:00
height : 36 px ;
box - sizing : border - box ;
2025-12-20 12:34:49 +08:00
}
2025-12-31 02:00:10 +08:00
select . control - btn {
appearance : none ;
padding - right : 28 px ;
background - image : url ( " data:image/svg+xml;charset=US-ASCII, % 3Csvg %20x mlns % 3D % 22http % 3A %2F %2F www.w3.org %2F 2000 %2F svg %22% 20width % 3D %22292.4% 22 %20he ight % 3D %22292.4% 22 %3E % 3Cpath %20f ill % 3D %22% 23FFFFFF %22% 20d % 3D % 22M287 %2069.4a 17.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.9c 3.6 %203.6% 207.8 %205.4% 2012.8 %205.4s 9.2-1.8 % 2012.8-5.4L287 %2095c 3.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 %2F svg %3E " ) ;
background - repeat : no - repeat ;
background - position : right 8 px center ;
background - size : 10 px ;
2025-12-20 12:34:49 +08:00
}
2025-12-31 02:00:10 +08:00
. control - btn . secondary { background - color : var ( - - secondary - color ) ; }
. control - btn . neutral { background - color : #64748b; }
. control - btn : hover { transform : translateY ( - 1 px ) ; }
. control - btn . copied { background - color : #2e7d32; }
. control - btn : disabled { opacity : 0.6 ; cursor : not - allowed ; }
2025-12-20 12:34:49 +08:00
. footer {
text - align : center ;
2025-12-31 02:00:10 +08:00
padding : 12 px ;
font - size : 0.85 em ;
color : var ( - - muted - text - color ) ;
background - color : var ( - - card - bg - color ) ;
2025-12-20 15:43:58 +08:00
border - top : 1 px solid var ( - - border - color ) ;
2025-12-31 02:00:10 +08:00
border - bottom - left - radius : var ( - - border - radius ) ;
border - bottom - right - radius : var ( - - border - radius ) ;
2025-12-20 12:34:49 +08:00
}
2025-12-31 02:00:10 +08:00
2025-12-20 12:34:49 +08:00
. footer a {
color : var ( - - primary - color ) ;
text - decoration : none ;
font - weight : 500 ;
}
2025-12-31 02:00:10 +08:00
. footer a : hover { text - decoration : underline ; }
2025-12-20 12:34:49 +08:00
. error - message {
color : #c62828;
background - color : #ffcdd2;
border : 1 px solid #ef9a9a;
2025-12-31 02:00:10 +08:00
padding : 14 px ;
2025-12-20 15:43:58 +08:00
border - radius : 8 px ;
2025-12-20 12:34:49 +08:00
font - weight : 500 ;
2025-12-20 15:43:58 +08:00
font - size : 1 em ;
2025-12-20 12:34:49 +08:00
}
2025-12-20 15:43:58 +08:00
"""
CONTENT_TEMPLATE_MINDMAP = """
< div class = " mindmap-container-wrapper " >
< div class = " header " >
< h1 > 🧠 Smart Mind Map < / h1 >
< / div >
< div class = " user-context " >
< span > < strong > User : < / strong > { user_name } < / span >
< span > < strong > Time : < / strong > { current_date_time_str } < / span >
< / div >
< div class = " content-area " >
< div class = " markmap-container " id = " markmap-container- {unique_id} " > < / div >
2025-12-31 02:00:10 +08:00
< 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 = " Zoom Out " > - < / button >
< button id = " zoom-reset-btn- {unique_id} " class = " control-btn neutral " title = " Reset " > Reset < / button >
< button id = " zoom-in-btn- {unique_id} " class = " control-btn neutral " title = " Zoom In " > + < / button >
< / div >
< div class = " btn-group " >
< select id = " depth-select- {unique_id} " class = " control-btn secondary " title = " Expand Level " >
< option value = " 0 " selected > Expand All < / option >
< option value = " 2 " > Level 2 < / option >
< option value = " 3 " > Level 3 < / option >
< / select >
< button id = " fullscreen-btn- {unique_id} " class = " control-btn " > Fullscreen < / button >
< button id = " theme-toggle-btn- {unique_id} " class = " control-btn neutral " > Theme < / button >
< / div >
2025-12-20 15:43:58 +08:00
< / div >
< / div >
< div class = " footer " >
< p > © { current_year } Smart Mind Map • < a href = " https://markmap.js.org/ " target = " _blank " > Markmap < / a > < / p >
2025-12-20 12:34:49 +08:00
< / div >
< / div >
2025-12-20 15:43:58 +08:00
< script type = " text/template " id = " markdown-source- {unique_id} " > { markdown_syntax } < / script >
"""
2025-12-20 12:34:49 +08:00
2025-12-20 15:43:58 +08:00
SCRIPT_TEMPLATE_MINDMAP = """
2025-12-20 12:34:49 +08:00
< script >
( function ( ) {
2025-12-31 02:00:10 +08:00
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 ( ' Loading failed: ' + 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 ( ' Loading failed: ' + 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 ;
} ;
2025-12-20 12:34:49 +08:00
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 ) {
2025-12-31 02:00:10 +08:00
containerEl . innerHTML = ' <div class= \" error-message \" >⚠️ Unable to load mind map: Missing valid content.</div> ' ;
2025-12-20 12:34:49 +08:00
return ;
}
2025-12-31 02:00:10 +08:00
ensureMarkmapReady ( ) . then ( ( ) = > {
const svgEl = document . createElementNS ( ' http://www.w3.org/2000/svg ' , ' svg ' ) ;
2025-12-20 12:34:49 +08:00
svgEl . style . width = ' 100 % ' ;
2025-12-31 02:00:10 +08:00
svgEl . style . height = ' 100 % ' ;
svgEl . style . minHeight = ' 60vh ' ;
containerEl . innerHTML = ' ' ;
2025-12-20 12:34:49 +08:00
containerEl . appendChild ( svgEl ) ;
const { Transformer , Markmap } = window . markmap ;
const transformer = new Transformer ( ) ;
const { root } = transformer . transform ( markdownContent ) ;
2025-12-31 02:00:10 +08:00
const style = ( id ) = > `
$ { id } text , $ { id } foreignObject { font - size : 14 px ; }
$ { id } foreignObject h1 { font - size : 22 px ; font - weight : 700 ; margin : 0 ; }
$ { id } foreignObject h2 { font - size : 18 px ; font - weight : 600 ; margin : 0 ; }
$ { id } foreignObject strong { font - weight : 700 ; }
` ;
const options = {
2025-12-20 12:34:49 +08:00
autoFit : true ,
2025-12-31 02:00:10 +08:00
style : style ,
initialExpandLevel : Infinity ,
zoom : true ,
pan : true
2025-12-20 12:34:49 +08:00
} ;
2025-12-31 02:00:10 +08:00
const markmapInstance = Markmap . create ( svgEl , options , root ) ;
2025-12-20 12:34:49 +08:00
containerEl . dataset . markmapRendered = ' true ' ;
2025-12-31 02:00:10 +08:00
setupControls ( {
containerEl ,
svgEl ,
markmapInstance ,
root ,
} ) ;
} ) . catch ( ( error ) = > {
console . error ( ' Markmap loading error: ' , error ) ;
containerEl . innerHTML = ' <div class= \" error-message \" >⚠️ Resource loading failed, please try again later.</div> ' ;
} ) ;
2025-12-20 12:34:49 +08:00
} ;
2025-12-31 02:00:10 +08:00
const setupControls = ( { containerEl , svgEl , markmapInstance , root } ) = > {
2025-12-20 12:34:49 +08:00
const downloadSvgBtn = document . getElementById ( ' download-svg-btn- ' + uniqueId ) ;
2025-12-31 02:00:10 +08:00
const downloadPngBtn = document . getElementById ( ' download-png-btn- ' + uniqueId ) ;
2025-12-20 12:34:49 +08:00
const downloadMdBtn = document . getElementById ( ' download-md-btn- ' + uniqueId ) ;
2025-12-31 02:00:10 +08:00
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 = ' Done ' , textFail = ' Failed ' ) = > {
if ( ! button ) return ;
const buttonText = button . querySelector ( ' .btn-text ' ) | | button ;
2025-12-20 12:34:49 +08:00
const originalText = buttonText . textContent ;
button . disabled = true ;
2025-12-31 02:00:10 +08:00
buttonText . textContent = textOk ;
button . classList . add ( ' copied ' ) ;
2025-12-20 12:34:49 +08:00
setTimeout ( ( ) = > {
buttonText . textContent = originalText ;
button . disabled = false ;
button . classList . remove ( ' copied ' ) ;
2025-12-31 02:00:10 +08:00
} , 1800 ) ;
2025-12-20 12:34:49 +08:00
} ;
const copyToClipboard = ( content , button ) = > {
if ( navigator . clipboard & & window . isSecureContext ) {
2025-12-31 02:00:10 +08:00
navigator . clipboard . writeText ( content ) . then ( ( ) = > showFeedback ( button ) , ( ) = > showFeedback ( button , ' Failed ' , ' Failed ' ) ) ;
2025-12-20 12:34:49 +08:00
} 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 ' ) ;
2025-12-31 02:00:10 +08:00
showFeedback ( button ) ;
2025-12-20 12:34:49 +08:00
} catch ( err ) {
2025-12-31 02:00:10 +08:00
showFeedback ( button , ' Failed ' , ' Failed ' ) ;
2025-12-20 12:34:49 +08:00
}
document . body . removeChild ( textArea ) ;
}
} ;
2025-12-31 02:00:10 +08:00
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 : 14 px ; }
h1 { font - size : 22 px ; font - weight : 700 ; margin : 0 ; }
h2 { font - size : 18 px ; 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 ) ;
} ;
2025-12-20 12:34:49 +08:00
2025-12-31 02:00:10 +08:00
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 = ' Generating... ' ;
btn . disabled = true ;
const svg = containerEl . querySelector ( ' svg ' ) ;
if ( ! svg ) {
btn . querySelector ( ' .btn-text ' ) . textContent = originalText ;
btn . disabled = false ;
showFeedback ( btn , ' Failed ' , ' Failed ' ) ;
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 : 14 px ; 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 , ' Failed ' , ' Failed ' ) ;
return ;
}
/ / Use non - bubbling MouseEvent to avoid router interception
const a = document . createElement ( ' a ' ) ;
a . download = ' mindmap.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 , ' Failed ' , ' Failed ' ) ;
} ;
img . src = dataUrl ;
} catch ( err ) {
console . error ( ' PNG export error: ' , err ) ;
btn . querySelector ( ' .btn-text ' ) . textContent = originalText ;
btn . disabled = false ;
showFeedback ( btn , ' Failed ' , ' Failed ' ) ;
}
} ;
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 ( ) ; } ) ;
2025-12-20 12:34:49 +08:00
} ;
if ( document . readyState == = ' loading ' ) {
document . addEventListener ( ' DOMContentLoaded ' , renderMindmap ) ;
} else {
renderMindmap ( ) ;
}
} ) ( ) ;
< / script >
"""
class Action :
class Valves ( BaseModel ) :
2025-12-20 17:03:40 +08:00
SHOW_STATUS : bool = Field (
2025-12-20 12:34:49 +08:00
default = True ,
description = " Whether to show action status updates in the chat interface. " ,
)
2025-12-20 17:03:40 +08:00
MODEL_ID : str = Field (
2025-12-20 14:59:55 +08:00
default = " " ,
description = " Built-in LLM model ID for text analysis. If empty, uses the current conversation ' s model. " ,
2025-12-20 12:34:49 +08:00
)
MIN_TEXT_LENGTH : int = Field (
default = 100 ,
description = " Minimum text length (character count) required for mind map analysis. " ,
)
2025-12-20 15:07:41 +08:00
CLEAR_PREVIOUS_HTML : bool = Field (
default = False ,
2025-12-20 15:43:58 +08:00
description = " Whether to force clear previous plugin results (if True, overwrites instead of merging). " ,
2025-12-20 15:07:41 +08:00
)
2025-12-28 20:08:50 +08:00
MESSAGE_COUNT : int = Field (
default = 1 ,
description = " Number of recent messages to use for generation. Set to 1 for just the last message, or higher for more context. " ,
)
2026-01-06 19:26:43 +08:00
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). " ,
)
2025-12-20 12:34:49 +08:00
def __init__ ( self ) :
self . valves = self . Valves ( )
self . weekday_map = {
" Monday " : " Monday " ,
" Tuesday " : " Tuesday " ,
" Wednesday " : " Wednesday " ,
" Thursday " : " Thursday " ,
" Friday " : " Friday " ,
" Saturday " : " Saturday " ,
" Sunday " : " Sunday " ,
}
2025-12-31 02:00:10 +08:00
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 " ) ,
" user_language " : user_data . get ( " language " , " en-US " ) ,
}
2026-01-06 19:26:43 +08:00
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 " "
2025-12-20 12:34:49 +08:00
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 output did not strictly follow the expected Markdown format, treating the entire output as summary. "
)
extracted_content = llm_output . strip ( )
return extracted_content . replace ( " </script> " , " < \\ /script> " )
2025-12-20 17:03:40 +08:00
async def _emit_status ( self , emitter , description : str , done : bool = False ) :
""" Emits a status update event. """
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 " ) :
""" Emits a notification event (info/success/warning/error). """
if emitter :
await emitter (
{ " type " : " notification " , " data " : { " type " : ntype , " content " : content } }
)
2025-12-20 15:07:41 +08:00
def _remove_existing_html ( self , content : str ) - > str :
""" Removes existing plugin-generated HTML code blocks from the content. """
pattern = r " ```html \ s*<!-- OPENWEBUI_PLUGIN_OUTPUT -->[ \ s \ S]*?``` "
return re . sub ( pattern , " " , content ) . strip ( )
2025-12-28 20:08:50 +08:00
def _extract_text_content ( self , content ) - > str :
""" Extract text from message content, supporting multimodal message formats """
if isinstance ( content , str ) :
return content
elif isinstance ( content , list ) :
# Multimodal message: [{"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 " "
2025-12-20 15:43:58 +08:00
def _merge_html (
self ,
existing_html_code : str ,
new_content : str ,
new_styles : str = " " ,
new_scripts : str = " " ,
user_language : str = " en-US " ,
) - > str :
"""
Merges new content into an existing HTML container , or creates a new one .
"""
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 ( )
2026-01-06 19:26:43 +08:00
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> " , " < \\ /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 : 14 px ; fill : #000000; }}
foreignObject , . markmap - foreign , . markmap - foreign div { { color : #000000; font-family: sans-serif; font-size: 14px; }}
h1 { { font - size : 22 px ; font - weight : 700 ; margin : 0 ; } }
h2 { { font - size : 18 px ; 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 ) ;
} }
} } ) ( ) ;
"""
2025-12-20 12:34:49 +08:00
async def action (
self ,
body : dict ,
__user__ : Optional [ Dict [ str , Any ] ] = None ,
__event_emitter__ : Optional [ Any ] = None ,
2026-01-06 19:26:43 +08:00
__event_call__ : Optional [ Callable [ [ Any ] , Awaitable [ None ] ] ] = None ,
__metadata__ : Optional [ dict ] = None ,
2025-12-20 12:34:49 +08:00
__request__ : Optional [ Request ] = None ,
) - > Optional [ dict ] :
2025-12-31 02:00:10 +08:00
logger . info ( " Action: Smart Mind Map (v0.8.0) 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 " ]
2025-12-20 12:34:49 +08:00
try :
2025-12-31 02:00:10 +08:00
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 ( " % B %d , % Y % H: % M: % S " )
current_weekday_en = now_dt . strftime ( " % A " )
2025-12-20 12:34:49 +08:00
current_weekday_zh = self . weekday_map . get ( current_weekday_en , " Unknown " )
2025-12-31 02:00:10 +08:00
current_year = now_dt . strftime ( " % Y " )
current_timezone_str = tz_env or " UTC "
2025-12-20 12:34:49 +08:00
except Exception as e :
logger . warning ( f " Failed to get timezone info: { e } , using default values. " )
now = datetime . now ( )
2025-12-20 15:43:58 +08:00
current_date_time_str = now . strftime ( " % B %d , % Y % H: % M: % S " )
2025-12-20 12:34:49 +08:00
current_weekday_zh = " Unknown "
current_year = now . strftime ( " % Y " )
current_timezone_str = " Unknown "
2025-12-20 17:03:40 +08:00
await self . _emit_notification (
__event_emitter__ ,
" Smart Mind Map is starting, generating mind map for you... " ,
" info " ,
)
2025-12-20 12:34:49 +08:00
messages = body . get ( " messages " )
2025-12-28 20:08:50 +08:00
if not messages or not isinstance ( messages , list ) :
2025-12-20 12:34:49 +08:00
error_message = " Unable to retrieve valid user message content. "
2025-12-20 17:03:40 +08:00
await self . _emit_notification ( __event_emitter__ , error_message , " error " )
2025-12-20 12:34:49 +08:00
return {
" messages " : [ { " role " : " assistant " , " content " : f " ❌ { error_message } " } ]
}
2025-12-28 20:08:50 +08:00
# 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 = (
" User "
if role == " user "
else " Assistant " if role == " assistant " else role
)
2026-01-04 03:14:28 +08:00
aggregated_parts . append ( f " { text_content } " )
2025-12-28 20:08:50 +08:00
if not aggregated_parts :
error_message = " Unable to retrieve valid user message content. "
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 )
2025-12-20 12:34:49 +08:00
long_text_content = " "
if parts :
for part in reversed ( parts ) :
if part . strip ( ) :
long_text_content = part . strip ( )
break
if not long_text_content :
2025-12-28 20:08:50 +08:00
long_text_content = original_content . strip ( )
2025-12-20 12:34:49 +08:00
if len ( long_text_content ) < self . valves . MIN_TEXT_LENGTH :
short_text_message = f " Text content is too short ( { len ( long_text_content ) } characters), unable to perform effective analysis. Please provide at least { self . valves . MIN_TEXT_LENGTH } characters of text. "
2025-12-20 17:03:40 +08:00
await self . _emit_notification (
__event_emitter__ , short_text_message , " warning "
)
2025-12-20 12:34:49 +08:00
return {
" messages " : [
{ " role " : " assistant " , " content " : f " ⚠️ { short_text_message } " }
]
}
2025-12-20 17:03:40 +08:00
await self . _emit_status (
__event_emitter__ ,
" Smart Mind Map: Analyzing text structure in depth... " ,
False ,
)
2025-12-20 12:34:49 +08:00
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 ,
)
2025-12-20 14:59:55 +08:00
# Determine model to use
2025-12-20 17:03:40 +08:00
target_model = self . valves . MODEL_ID
2025-12-20 14:59:55 +08:00
if not target_model :
target_model = body . get ( " model " )
2025-12-20 12:34:49 +08:00
llm_payload = {
2025-12-20 14:59:55 +08:00
" model " : target_model ,
2025-12-20 12:34:49 +08:00
" messages " : [
{ " role " : " system " , " content " : SYSTEM_PROMPT_MINDMAP_ASSISTANT } ,
{ " role " : " user " , " content " : formatted_user_prompt } ,
] ,
" temperature " : 0.5 ,
" stream " : False ,
}
user_obj = Users . get_user_by_id ( user_id )
if not user_obj :
raise ValueError ( f " Unable to get user object, user ID: { 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 response format is incorrect or empty. " )
assistant_response_content = llm_response [ " choices " ] [ 0 ] [ " message " ] [
" content "
]
markdown_syntax = self . _extract_markdown_syntax ( assistant_response_content )
2025-12-20 15:43:58 +08:00
# Prepare content components
content_html = (
CONTENT_TEMPLATE_MINDMAP . replace ( " {unique_id} " , unique_id )
2025-12-20 12:34:49 +08:00
. replace ( " {user_name} " , user_name )
. replace ( " {current_date_time_str} " , current_date_time_str )
. replace ( " {current_year} " , current_year )
. replace ( " {markdown_syntax} " , markdown_syntax )
)
2025-12-20 15:43:58 +08:00
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 )
2025-12-20 15:07:41 +08:00
if self . valves . CLEAR_PREVIOUS_HTML :
long_text_content = self . _remove_existing_html ( long_text_content )
2025-12-20 15:43:58 +08:00
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 ,
)
2026-01-06 19:26:43 +08:00
# 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
2025-12-20 15:43:58 +08:00
html_embed_tag = f " ```html \n { final_html } \n ``` "
2025-12-20 12:34:49 +08:00
body [ " messages " ] [ - 1 ] [ " content " ] = f " { long_text_content } \n \n { html_embed_tag } "
2025-12-20 17:03:40 +08:00
await self . _emit_status (
__event_emitter__ , " Smart Mind Map: Drawing completed! " , True
)
await self . _emit_notification (
__event_emitter__ ,
f " Mind map has been generated, { user_name } ! " ,
" success " ,
)
2026-01-06 19:26:43 +08:00
logger . info ( " Action: Smart Mind Map (v0.9.0) completed in HTML mode " )
2025-12-20 12:34:49 +08:00
except Exception as e :
error_message = f " Smart Mind Map processing failed: { str ( e ) } "
logger . error ( f " Smart Mind Map error: { error_message } " , exc_info = True )
user_facing_error = f " Sorry, Smart Mind Map encountered an error during processing: { str ( e ) } . \n Please check the Open WebUI backend logs for more details. "
body [ " messages " ] [ - 1 ] [
" content "
] = f " { long_text_content } \n \n ❌ **Error:** { user_facing_error } "
2025-12-20 17:03:40 +08:00
await self . _emit_status (
__event_emitter__ , " Smart Mind Map: Processing failed. " , True
)
await self . _emit_notification (
__event_emitter__ ,
f " Smart Mind Map generation failed, { user_name } ! " ,
" error " ,
)
2025-12-20 12:34:49 +08:00
return body