- Add prominent star icon in download button group linking to repository - Style with gold gradient color (#fbbf24) for visibility - Hover effect with scale and glow for better UX - Icon uses yellow color (#fbbf24) with transparent background - Helps users easily discover and support the project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2888 lines
129 KiB
Python
2888 lines
129 KiB
Python
"""
|
||
title: Smart Mind Map
|
||
author: Fu-Jie
|
||
author_url: https://github.com/Fu-Jie/openwebui-extensions
|
||
funding_url: https://github.com/open-webui
|
||
version: 1.0.0
|
||
openwebui_id: 3094c59a-b4dd-4e0c-9449-15e2dd547dc4
|
||
icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSIyIiB5PSIxNiIgd2lkdGg9IjYiIGhlaWdodD0iNiIgcng9IjEiLz48cmVjdCB4PSI5IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSI2IiByeD0iMSIvPjxwYXRoIGQ9Ik01IDE2di0zYTEgMSAwIDAgMSAxLTFoMTJhMSAxIDAgMCAxIDEgMXYzIi8+PHBhdGggZD0iTTEyIDEyVjgiLz48L3N2Zz4=
|
||
description: Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import re
|
||
import time
|
||
import json
|
||
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
|
||
|
||
try:
|
||
from open_webui.env import VERSION as open_webui_version
|
||
except ImportError:
|
||
open_webui_version = "0.0.0"
|
||
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TRANSLATIONS = {
|
||
"en-US": {
|
||
"status_starting": "Smart Mind Map is starting, generating mind map for you...",
|
||
"error_no_content": "Unable to retrieve valid user message content.",
|
||
"error_text_too_short": "Text content is too short ({len} characters), unable to perform effective analysis. Please provide at least {min_len} characters of text.",
|
||
"status_analyzing": "Smart Mind Map: Analyzing text structure in depth...",
|
||
"status_drawing": "Smart Mind Map: Drawing completed!",
|
||
"notification_success": "Mind map has been generated, {user_name}!",
|
||
"error_processing": "Smart Mind Map processing failed: {error}",
|
||
"error_user_facing": "Sorry, Smart Mind Map encountered an error during processing: {error}.\nPlease check the Open WebUI backend logs for more details.",
|
||
"status_failed": "Smart Mind Map: Processing failed.",
|
||
"notification_failed": "Smart Mind Map generation failed, {user_name}!",
|
||
"status_rendering_image": "Smart Mind Map: Rendering image...",
|
||
"status_image_generated": "Smart Mind Map: Image generated!",
|
||
"notification_image_success": "Mind map image has been generated, {user_name}!",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "User:",
|
||
"ui_time": "Time:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Reset",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Expand Level",
|
||
"ui_depth_all": "Expand All",
|
||
"ui_depth_2": "Level 2",
|
||
"ui_depth_3": "Level 3",
|
||
"ui_fullscreen": "Fullscreen",
|
||
"ui_theme": "Theme",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ Unable to load mind map: Missing valid content.",
|
||
"html_error_load_failed": "⚠️ Resource loading failed, please try again later.",
|
||
"js_done": "Done",
|
||
"js_failed": "Failed",
|
||
"js_generating": "Generating...",
|
||
"js_filename": "mindmap.png",
|
||
"js_upload_failed": "Upload failed: ",
|
||
"md_image_alt": "🧠 Mind Map",
|
||
},
|
||
"zh-CN": {
|
||
"status_starting": "思维导图已启动,正在为您生成思维导图...",
|
||
"error_no_content": "无法获取有效的用户消息内容。",
|
||
"error_text_too_short": "文本内容过短({len}字符),无法进行有效分析。请提供至少{min_len}字符的文本。",
|
||
"status_analyzing": "思维导图:深入分析文本结构...",
|
||
"status_drawing": "思维导图:绘制完成!",
|
||
"notification_success": "思维导图已生成,{user_name}!",
|
||
"error_processing": "思维导图处理失败:{error}",
|
||
"error_user_facing": "抱歉,思维导图在处理时遇到错误:{error}。\n请检查Open WebUI后端日志获取更多详情。",
|
||
"status_failed": "思维导图:处理失败。",
|
||
"notification_failed": "思维导图生成失败,{user_name}!",
|
||
"status_rendering_image": "思维导图:正在渲染图片...",
|
||
"status_image_generated": "思维导图:图片已生成!",
|
||
"notification_image_success": "思维导图图片已生成,{user_name}!",
|
||
"ui_title": "🧠 智能思维导图",
|
||
"ui_user": "用户:",
|
||
"ui_time": "时间:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "缩小",
|
||
"ui_zoom_reset": "重置",
|
||
"ui_zoom_in": "放大",
|
||
"ui_depth_select": "展开层级",
|
||
"ui_depth_all": "全部展开",
|
||
"ui_depth_2": "展开 2 级",
|
||
"ui_depth_3": "展开 3 级",
|
||
"ui_fullscreen": "全屏",
|
||
"ui_theme": "主题",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ 无法加载思维导图:缺少有效内容。",
|
||
"html_error_load_failed": "⚠️ 资源加载失败,请稍后重试。",
|
||
"js_done": "完成",
|
||
"js_failed": "失败",
|
||
"js_generating": "生成中...",
|
||
"js_filename": "思维导图.png",
|
||
"js_upload_failed": "上传失败:",
|
||
"md_image_alt": "🧠 思维导图",
|
||
},
|
||
"zh-HK": {
|
||
"status_starting": "思維導圖已啟動,正在為您生成思維導圖...",
|
||
"error_no_content": "無法獲取有效的用戶消息內容。",
|
||
"error_text_too_short": "文本內容過短({len}字元),無法進行有效分析。請提供至少{min_len}字元的文本。",
|
||
"status_analyzing": "思維導圖:深入分析文本結構...",
|
||
"status_drawing": "思維導圖:繪製完成!",
|
||
"notification_success": "思維導圖已生成,{user_name}!",
|
||
"error_processing": "思維導圖處理失敗:{error}",
|
||
"error_user_facing": "抱歉,思維導圖在處理時遇到錯誤:{error}。\n請檢查Open WebUI後端日誌獲取更多詳情。",
|
||
"status_failed": "思維導圖:處理失敗。",
|
||
"notification_failed": "思維導圖生成失敗,{user_name}!",
|
||
"status_rendering_image": "思維導圖:正在渲染圖片...",
|
||
"status_image_generated": "思維導圖:圖片已生成!",
|
||
"notification_image_success": "思維導圖圖片已生成,{user_name}!",
|
||
"ui_title": "🧠 智能思維導圖",
|
||
"ui_user": "用戶:",
|
||
"ui_time": "時間:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "縮小",
|
||
"ui_zoom_reset": "重置",
|
||
"ui_zoom_in": "放大",
|
||
"ui_depth_select": "展開層級",
|
||
"ui_depth_all": "全部展開",
|
||
"ui_depth_2": "展開 2 級",
|
||
"ui_depth_3": "展開 3 級",
|
||
"ui_fullscreen": "全屏",
|
||
"ui_theme": "主題",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ 無法加載思維導圖:缺少有效內容。",
|
||
"html_error_load_failed": "⚠️ 資源加載失敗,請稍後重試。",
|
||
"js_done": "完成",
|
||
"js_failed": "失敗",
|
||
"js_generating": "生成中...",
|
||
"js_filename": "思維導圖.png",
|
||
"js_upload_failed": "上傳失敗:",
|
||
"md_image_alt": "🧠 思維導圖",
|
||
},
|
||
"zh-TW": {
|
||
"status_starting": "思維導圖已啟動,正在為您生成思維導圖...",
|
||
"error_no_content": "無法獲取有效的用戶消息內容。",
|
||
"error_text_too_short": "文本內容過短({len}字元),無法進行有效分析。請提供至少{min_len}字元的文本。",
|
||
"status_analyzing": "思維導圖:深入分析文本結構...",
|
||
"status_drawing": "思維導圖:繪製完成!",
|
||
"notification_success": "思維導圖已生成,{user_name}!",
|
||
"error_processing": "思維導圖處理失敗:{error}",
|
||
"error_user_facing": "抱歉,思維導圖在處理時遇到錯誤:{error}。\n請檢查Open WebUI後端日誌獲取更多詳情。",
|
||
"status_failed": "思維導圖:處理失敗。",
|
||
"notification_failed": "思維導圖生成失敗,{user_name}!",
|
||
"status_rendering_image": "思維導圖:正在渲染圖片...",
|
||
"status_image_generated": "思維導圖:圖片已生成!",
|
||
"notification_image_success": "思維導圖圖片已生成,{user_name}!",
|
||
"ui_title": "🧠 智能思維導圖",
|
||
"ui_user": "用戶:",
|
||
"ui_time": "時間:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "縮小",
|
||
"ui_zoom_reset": "重置",
|
||
"ui_zoom_in": "放大",
|
||
"ui_depth_select": "展開層級",
|
||
"ui_depth_all": "全部展開",
|
||
"ui_depth_2": "展開 2 級",
|
||
"ui_depth_3": "展開 3 級",
|
||
"ui_fullscreen": "全屏",
|
||
"ui_theme": "主題",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ 無法加載思維導圖:缺少有效內容。",
|
||
"html_error_load_failed": "⚠️ 資源加載失敗,請稍後重試。",
|
||
"js_done": "完成",
|
||
"js_failed": "失敗",
|
||
"js_generating": "生成中...",
|
||
"js_filename": "思維導圖.png",
|
||
"js_upload_failed": "上傳失敗:",
|
||
"md_image_alt": "🧠 思維導圖",
|
||
},
|
||
"ko-KR": {
|
||
"status_starting": "스마트 마인드맵이 시작되었습니다, 마인드맵을 생성 중입니다...",
|
||
"error_no_content": "유효한 사용자 메시지 내용을 가져올 수 없습니다.",
|
||
"error_text_too_short": "텍스트 내용이 너무 짧아({len}자), 효과적인 분석을 수행할 수 없습니다. 최소 {min_len}자 이상의 텍스트를 제공해 주세요.",
|
||
"status_analyzing": "스마트 마인드맵: 텍스트 구조 심층 분석 중...",
|
||
"status_drawing": "스마트 마인드맵: 그리기 완료!",
|
||
"notification_success": "마인드맵이 생성되었습니다, {user_name}님!",
|
||
"error_processing": "스마트 마인드맵 처리 실패: {error}",
|
||
"error_user_facing": "죄송합니다, 스마트 마인드맵 처리 중 오류가 발생했습니다: {error}.\n자세한 내용은 Open WebUI 백엔드 로그를 확인해 주세요.",
|
||
"status_failed": "스마트 마인드맵: 처리 실패.",
|
||
"notification_failed": "스마트 마인드맵 생성 실패, {user_name}님!",
|
||
"status_rendering_image": "스마트 마인드맵: 이미지 렌더링 중...",
|
||
"status_image_generated": "스마트 마인드맵: 이미지 생성됨!",
|
||
"notification_image_success": "마인드맵 이미지가 생성되었습니다, {user_name}님!",
|
||
"ui_title": "🧠 스마트 마인드맵",
|
||
"ui_user": "사용자:",
|
||
"ui_time": "시간:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "초기화",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "레벨 확장",
|
||
"ui_depth_all": "모두 확장",
|
||
"ui_depth_2": "레벨 2",
|
||
"ui_depth_3": "레벨 3",
|
||
"ui_fullscreen": "전체 화면",
|
||
"ui_theme": "테마",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ 마인드맵을 로드할 수 없습니다: 유효한 내용이 없습니다.",
|
||
"html_error_load_failed": "⚠️ 리소스 로드 실패, 나중에 다시 시도해 주세요.",
|
||
"js_done": "완료",
|
||
"js_failed": "실패",
|
||
"js_generating": "생성 중...",
|
||
"js_filename": "mindmap.png",
|
||
"js_upload_failed": "업로드 실패: ",
|
||
"md_image_alt": "🧠 마인드맵",
|
||
},
|
||
"ja-JP": {
|
||
"status_starting": "スマートマインドマップが起動しました。マインドマップを生成しています...",
|
||
"error_no_content": "有効なユーザーメッセージの内容を取得できませんでした。",
|
||
"error_text_too_short": "テキストの内容が短すぎるため({len}文字)、効果的な分析を実行できません。少なくとも{min_len}文字のテキストを提供してください。",
|
||
"status_analyzing": "スマートマインドマップ:テキスト構造を詳細に分析中...",
|
||
"status_drawing": "スマートマインドマップ:描画完了!",
|
||
"notification_success": "マインドマップが生成されました、{user_name}さん!",
|
||
"error_processing": "スマートマインドマップ処理失敗:{error}",
|
||
"error_user_facing": "申し訳ありません、スマートマインドマップの処理中にエラーが発生しました:{error}。\n詳細については、Open WebUIバックエンドログを確認してください。",
|
||
"status_failed": "スマートマインドマップ:処理失敗。",
|
||
"notification_failed": "スマートマインドマップ生成失敗、{user_name}さん!",
|
||
"status_rendering_image": "スマートマインドマップ:画像レンダリング中...",
|
||
"status_image_generated": "スマートマインドマップ:画像生成完了!",
|
||
"notification_image_success": "マインドマップ画像が生成されました、{user_name}さん!",
|
||
"ui_title": "🧠 スマートマインドマップ",
|
||
"ui_user": "ユーザー:",
|
||
"ui_time": "時間:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "リセット",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "レベル展開",
|
||
"ui_depth_all": "すべて展開",
|
||
"ui_depth_2": "レベル2",
|
||
"ui_depth_3": "レベル3",
|
||
"ui_fullscreen": "全画面",
|
||
"ui_theme": "テーマ",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ マインドマップを読み込めません:有効なコンテンツがありません。",
|
||
"html_error_load_failed": "⚠️ リソースの読み込みに失敗しました。後でもう一度お試しください。",
|
||
"js_done": "完了",
|
||
"js_failed": "失敗",
|
||
"js_generating": "生成中...",
|
||
"js_filename": "mindmap.png",
|
||
"js_upload_failed": "アップロード失敗:",
|
||
"md_image_alt": "🧠 マインドマップ",
|
||
},
|
||
"fr-FR": {
|
||
"status_starting": "Smart Mind Map démarre, génération de la carte heuristique en cours...",
|
||
"error_no_content": "Impossible de récupérer le contenu valide du message utilisateur.",
|
||
"error_text_too_short": "Le contenu du texte est trop court ({len} caractères), impossible d'effectuer une analyse efficace. Veuillez fournir au moins {min_len} caractères de texte.",
|
||
"status_analyzing": "Smart Mind Map : Analyse approfondie de la structure du texte...",
|
||
"status_drawing": "Smart Mind Map : Dessin terminé !",
|
||
"notification_success": "La carte heuristique a été générée, {user_name} !",
|
||
"error_processing": "Échec du traitement de Smart Mind Map : {error}",
|
||
"error_user_facing": "Désolé, Smart Mind Map a rencontré une erreur lors du traitement : {error}.\nVeuillez vérifier les journaux backend d'Open WebUI pour plus de détails.",
|
||
"status_failed": "Smart Mind Map : Échec du traitement.",
|
||
"notification_failed": "Échec de la génération de la carte heuristique, {user_name} !",
|
||
"status_rendering_image": "Smart Mind Map : Rendu de l'image...",
|
||
"status_image_generated": "Smart Mind Map : Image générée !",
|
||
"notification_image_success": "L'image de la carte heuristique a été générée, {user_name} !",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "Utilisateur :",
|
||
"ui_time": "Heure :",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Rénitialiser",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Niveau d'expansion",
|
||
"ui_depth_all": "Tout développer",
|
||
"ui_depth_2": "Niveau 2",
|
||
"ui_depth_3": "Niveau 3",
|
||
"ui_fullscreen": "Plein écran",
|
||
"ui_theme": "Thème",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ Impossible de charger la carte heuristique : contenu valide manquant.",
|
||
"html_error_load_failed": "⚠️ Échec du chargement des ressources, veuillez réessayer plus tard.",
|
||
"js_done": "Terminé",
|
||
"js_failed": "Échec",
|
||
"js_generating": "Génération...",
|
||
"js_filename": "carte_heuristique.png",
|
||
"js_upload_failed": "Échec du téléchargement : ",
|
||
"md_image_alt": "🧠 Carte Heuristique",
|
||
},
|
||
"de-DE": {
|
||
"status_starting": "Smart Mind Map startet, Mindmap wird für Sie erstellt...",
|
||
"error_no_content": "Gültiger Inhalt der Benutzernachricht konnte nicht abgerufen werden.",
|
||
"error_text_too_short": "Der Textinhalt ist zu kurz ({len} Zeichen), eine effektive Analyse ist nicht möglich. Bitte geben Sie mindestens {min_len} Zeichen Text an.",
|
||
"status_analyzing": "Smart Mind Map: Detaillierte Analyse der Textstruktur...",
|
||
"status_drawing": "Smart Mind Map: Zeichnen abgeschlossen!",
|
||
"notification_success": "Mindmap wurde erstellt, {user_name}!",
|
||
"error_processing": "Smart Mind Map Verarbeitung fehlgeschlagen: {error}",
|
||
"error_user_facing": "Entschuldigung, bei der Verarbeitung von Smart Mind Map ist ein Fehler aufgetreten: {error}.\nBitte überprüfen Sie die Open WebUI Backend-Protokolle für weitere Details.",
|
||
"status_failed": "Smart Mind Map: Verarbeitung fehlgeschlagen.",
|
||
"notification_failed": "Erstellung der Mindmap fehlgeschlagen, {user_name}!",
|
||
"status_rendering_image": "Smart Mind Map: Bild wird gerendert...",
|
||
"status_image_generated": "Smart Mind Map: Bild erstellt!",
|
||
"notification_image_success": "Mindmap-Bild wurde erstellt, {user_name}!",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "Benutzer:",
|
||
"ui_time": "Zeit:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Zurücksetzen",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Ebene erweitern",
|
||
"ui_depth_all": "Alles erweitern",
|
||
"ui_depth_2": "Ebene 2",
|
||
"ui_depth_3": "Ebene 3",
|
||
"ui_fullscreen": "Vollbild",
|
||
"ui_theme": "Thema",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ Mindmap kann nicht geladen werden: Gültiger Inhalt fehlt.",
|
||
"html_error_load_failed": "⚠️ Ressourcenladen fehlgeschlagen, bitte versuchen Sie es später erneut.",
|
||
"js_done": "Fertig",
|
||
"js_failed": "Fehlgeschlagen",
|
||
"js_generating": "Generiere...",
|
||
"js_filename": "mindmap.png",
|
||
"js_upload_failed": "Upload fehlgeschlagen: ",
|
||
"md_image_alt": "🧠 Mindmap",
|
||
},
|
||
"es-ES": {
|
||
"status_starting": "Smart Mind Map se está iniciando, generando mapa mental para usted...",
|
||
"error_no_content": "No se puede recuperar el contenido válido del mensaje del usuario.",
|
||
"error_text_too_short": "El contenido del texto es demasiado corto ({len} caracteres), no se puede realizar un análisis efectivo. Proporcione al menos {min_len} caracteres de texto.",
|
||
"status_analyzing": "Smart Mind Map: Analizando la estructura del texto en profundidad...",
|
||
"status_drawing": "Smart Mind Map: ¡Dibujo completado!",
|
||
"notification_success": "¡El mapa mental ha sido generado, {user_name}!",
|
||
"error_processing": "Falló el procesamiento de Smart Mind Map: {error}",
|
||
"error_user_facing": "Lo sentimos, Smart Mind Map encontró un error durante el procesamiento: {error}.\nConsulte los registros del backend de Open WebUI para más detalles.",
|
||
"status_failed": "Smart Mind Map: Procesamiento fallido.",
|
||
"notification_failed": "¡La generación del mapa mental falló, {user_name}!",
|
||
"status_rendering_image": "Smart Mind Map: Renderizando imagen...",
|
||
"status_image_generated": "Smart Mind Map: ¡Imagen generada!",
|
||
"notification_image_success": "¡La imagen del mapa mental ha sido generada, {user_name}!",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "Usuario:",
|
||
"ui_time": "Hora:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Restablecer",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Expandir Nivel",
|
||
"ui_depth_all": "Expandir Todo",
|
||
"ui_depth_2": "Nivel 2",
|
||
"ui_depth_3": "Nivel 3",
|
||
"ui_fullscreen": "Pantalla completa",
|
||
"ui_theme": "Tema",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ No se puede cargar el mapa mental: Falta contenido válido.",
|
||
"html_error_load_failed": "⚠️ Falló la carga de recursos, inténtelo de nuevo más tarde.",
|
||
"js_done": "Hecho",
|
||
"js_failed": "Fallido",
|
||
"js_generating": "Generando...",
|
||
"js_filename": "mapa_mental.png",
|
||
"js_upload_failed": "Carga fallida: ",
|
||
"md_image_alt": "🧠 Mapa Mental",
|
||
},
|
||
"it-IT": {
|
||
"status_starting": "Smart Mind Map si sta avviando, generazione mappa mentale in corso...",
|
||
"error_no_content": "Impossibile recuperare il contenuto valido del messaggio utente.",
|
||
"error_text_too_short": "Il testo è troppo breve ({len} caratteri), impossibile eseguire un'analisi efficace. Fornire almeno {min_len} caratteri di testo.",
|
||
"status_analyzing": "Smart Mind Map: Analisi approfondita della struttura del testo...",
|
||
"status_drawing": "Smart Mind Map: Disegno completato!",
|
||
"notification_success": "La mappa mentale è stata generata, {user_name}!",
|
||
"error_processing": "Elaborazione Smart Mind Map fallita: {error}",
|
||
"error_user_facing": "Spiacenti, Smart Mind Map ha riscontrato un errore durante l'elaborazione: {error}.\nControllare i log del backend di Open WebUI per ulteriori dettagli.",
|
||
"status_failed": "Smart Mind Map: Elaborazione fallita.",
|
||
"notification_failed": "Generazione mappa mentale fallita, {user_name}!",
|
||
"status_rendering_image": "Smart Mind Map: Rendering immagine...",
|
||
"status_image_generated": "Smart Mind Map: Immagine generata!",
|
||
"notification_image_success": "L'immagine della mappa mentale è stata generata, {user_name}!",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "Utente:",
|
||
"ui_time": "Ora:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Reimposta",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Espandi Livello",
|
||
"ui_depth_all": "Espandi Tutto",
|
||
"ui_depth_2": "Livello 2",
|
||
"ui_depth_3": "Livello 3",
|
||
"ui_fullscreen": "Schermo intero",
|
||
"ui_theme": "Tema",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ Impossibile caricare la mappa mentale: Contenuto valido mancante.",
|
||
"html_error_load_failed": "⚠️ Caricamento risorse fallito, riprovare più tardi.",
|
||
"js_done": "Fatto",
|
||
"js_failed": "Fallito",
|
||
"js_generating": "Generazione...",
|
||
"js_filename": "mappa_mentale.png",
|
||
"js_upload_failed": "Caricamento fallito: ",
|
||
"md_image_alt": "🧠 Mappa Mentale",
|
||
},
|
||
"vi-VN": {
|
||
"status_starting": "Smart Mind Map đang khởi động, đang tạo sơ đồ tư duy cho bạn...",
|
||
"error_no_content": "Không thể lấy nội dung tin nhắn người dùng hợp lệ.",
|
||
"error_text_too_short": "Nội dung văn bản quá ngắn ({len} ký tự), không thể thực hiện phân tích hiệu quả. Vui lòng cung cấp ít nhất {min_len} ký tự văn bản.",
|
||
"status_analyzing": "Smart Mind Map: Phân tích sâu cấu trúc văn bản...",
|
||
"status_drawing": "Smart Mind Map: Vẽ hoàn tất!",
|
||
"notification_success": "Sơ đồ tư duy đã được tạo, {user_name}!",
|
||
"error_processing": "Xử lý Smart Mind Map thất bại: {error}",
|
||
"error_user_facing": "Xin lỗi, Smart Mind Map đã gặp lỗi trong quá trình xử lý: {error}.\nVui lòng kiểm tra nhật ký backend Open WebUI để biết thêm chi tiết.",
|
||
"status_failed": "Smart Mind Map: Xử lý thất bại.",
|
||
"notification_failed": "Tạo sơ đồ tư duy thất bại, {user_name}!",
|
||
"status_rendering_image": "Smart Mind Map: Đang render hình ảnh...",
|
||
"status_image_generated": "Smart Mind Map: Hình ảnh đã tạo!",
|
||
"notification_image_success": "Hình ảnh sơ đồ tư duy đã được tạo, {user_name}!",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "Người dùng:",
|
||
"ui_time": "Thời gian:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Đặt lại",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Mở rộng Cấp độ",
|
||
"ui_depth_all": "Mở rộng Tất cả",
|
||
"ui_depth_2": "Cấp độ 2",
|
||
"ui_depth_3": "Cấp độ 3",
|
||
"ui_fullscreen": "Toàn màn hình",
|
||
"ui_theme": "Chủ đề",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ Không thể tải sơ đồ tư duy: Thiếu nội dung hợp lệ.",
|
||
"html_error_load_failed": "⚠️ Tải tài nguyên thất bại, vui lòng thử lại sau.",
|
||
"js_done": "Xong",
|
||
"js_failed": "Thất bại",
|
||
"js_generating": "Đang tạo...",
|
||
"js_filename": "sodo_tuduy.png",
|
||
"js_upload_failed": "Tải lên thất bại: ",
|
||
"md_image_alt": "🧠 Sơ đồ Tư duy",
|
||
},
|
||
"id-ID": {
|
||
"status_starting": "Smart Mind Map sedang dimulai, membuat peta pikiran untuk Anda...",
|
||
"error_no_content": "Tidak dapat mengambil konten pesan pengguna yang valid.",
|
||
"error_text_too_short": "Konten teks terlalu pendek ({len} karakter), tidak dapat melakukan analisis efektif. Harap berikan setidaknya {min_len} karakter teks.",
|
||
"status_analyzing": "Smart Mind Map: Menganalisis struktur teks secara mendalam...",
|
||
"status_drawing": "Smart Mind Map: Menggambar selesai!",
|
||
"notification_success": "Peta pikiran telah dibuat, {user_name}!",
|
||
"error_processing": "Pemrosesan Smart Mind Map gagal: {error}",
|
||
"error_user_facing": "Maaf, Smart Mind Map mengalami kesalahan saat memproses: {error}.\nSilakan periksa log backend Open WebUI untuk detail lebih lanjut.",
|
||
"status_failed": "Smart Mind Map: Pemrosesan gagal.",
|
||
"notification_failed": "Pembuatan peta pikiran gagal, {user_name}!",
|
||
"status_rendering_image": "Smart Mind Map: Merender gambar...",
|
||
"status_image_generated": "Smart Mind Map: Gambar dibuat!",
|
||
"notification_image_success": "Gambar peta pikiran telah dibuat, {user_name}!",
|
||
"ui_title": "🧠 Smart Mind Map",
|
||
"ui_user": "Pengguna:",
|
||
"ui_time": "Waktu:",
|
||
"ui_download_png": "PNG",
|
||
"ui_download_svg": "SVG",
|
||
"ui_download_md": "Markdown",
|
||
"ui_zoom_out": "-",
|
||
"ui_zoom_reset": "Atur Ulang",
|
||
"ui_zoom_in": "+",
|
||
"ui_depth_select": "Perluas Level",
|
||
"ui_depth_all": "Perluas Semua",
|
||
"ui_depth_2": "Level 2",
|
||
"ui_depth_3": "Level 3",
|
||
"ui_fullscreen": "Layar Penuh",
|
||
"ui_theme": "Tema",
|
||
"ui_footer": "<b>Powered by</b> <a href='https://markmap.js.org/' target='_blank' rel='noopener noreferrer'>Markmap</a>",
|
||
"html_error_missing_content": "⚠️ Tidak dapat memuat peta pikiran: Konten valid hilang.",
|
||
"html_error_load_failed": "⚠️ Gagal memuat sumber daya, silakan coba lagi nanti.",
|
||
"js_done": "Selesai",
|
||
"js_failed": "Gagal",
|
||
"js_generating": "Membuat...",
|
||
"js_filename": "peta_pikiran.png",
|
||
"js_upload_failed": "Unggah gagal: ",
|
||
"md_image_alt": "🧠 Peta Pikiran",
|
||
},
|
||
}
|
||
|
||
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 exact same language as the input text (the text you are analyzing).
|
||
- **Format Consistency**: Even if this system prompt is in English, if the user input is in Chinese, the mind map content must be in Chinese. If input is Japanese, output Japanese.
|
||
- **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.
|
||
- **Root Node (Central Theme) — Strict Length Limits**:
|
||
- The `#` root node must be an ultra-compact title, like a newspaper headline. It should be a keyword or short phrase, NEVER a full sentence.
|
||
- **CJK scripts (Chinese, Japanese, Korean)**: Maximum **10 characters** (e.g., `# 老人缓解呼吸困难方法` ✓ / `# 老人在家时感到呼吸困难的缓解方法` ✗)
|
||
- **Latin-script languages (English, Spanish, French, Italian, Portuguese)**: Maximum **5 words or 35 characters** (e.g., `# Methods to Relieve Dyspnea` ✓ / `# How Elderly People Can Relieve Breathing Difficulty at Home` ✗)
|
||
- **German, Dutch or languages with long compound words**: Maximum **4 words or 30 characters**
|
||
- **Arabic, Hebrew and other RTL scripts**: Maximum **5 words or 25 characters**
|
||
- **All other languages**: Maximum **5 words or 30 characters**
|
||
- If the identified theme would exceed the limit, distill it further into the single most essential keyword or 2-3 word phrase.
|
||
- **Branch Node Content**:
|
||
- 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
|
||
```
|
||
- **Awareness of Target Audience Layout**: You will be provided `Target Rendering Mode`.
|
||
- If `Target Rendering Mode` is `direct`: The client has massive horizontal space but limited scrolling vertically. Extract more first-level concepts to make the mind map spread wide like a sprawling fan, rather than deep single columns.
|
||
- If `Target Rendering Mode` is `legacy`: The client uses a narrow, portrait sidebar. Extract fewer top-level nodes, and break points into deeper, tighter sub-branches so the map grows vertically downwards.
|
||
"""
|
||
|
||
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}
|
||
Target Rendering Mode: Auto-adapting (Dynamic width based on viewport)
|
||
---
|
||
|
||
**Long-form Text Content:**
|
||
{long_text_content}
|
||
"""
|
||
|
||
HTML_WRAPPER_TEMPLATE = """
|
||
<!-- OPENWEBUI_PLUGIN_OUTPUT -->
|
||
<!DOCTYPE html>
|
||
<html lang="{lang}">
|
||
<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: 2px;
|
||
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);
|
||
}
|
||
html, body {
|
||
margin: 0;
|
||
padding: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: var(--card-bg-color);
|
||
overflow: hidden;
|
||
}
|
||
.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;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--card-bg-color);
|
||
width: 100vw;
|
||
height: 100vh;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
border: none;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
}
|
||
.header {
|
||
background: var(--card-bg-color);
|
||
color: var(--text-color);
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
border-bottom: 1px solid var(--border-color);
|
||
z-index: 10;
|
||
}
|
||
.header-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.header h1 {
|
||
margin: 0;
|
||
font-size: 1.2em;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.header-credits {
|
||
font-size: 0.8em;
|
||
color: var(--muted-text-color);
|
||
opacity: 0.8;
|
||
white-space: nowrap;
|
||
}
|
||
.header-credits a {
|
||
color: var(--primary-color);
|
||
text-decoration: none;
|
||
border-bottom: 1px dotted var(--link-color);
|
||
}
|
||
.star-btn {
|
||
background: transparent !important;
|
||
border: none !important;
|
||
color: #fbbf24 !important;
|
||
display: inline-flex !important;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 4px 8px !important;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
text-decoration: none;
|
||
height: 28px;
|
||
}
|
||
.star-btn:hover {
|
||
color: #f59e0b !important;
|
||
transform: scale(1.15);
|
||
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.5));
|
||
}
|
||
.star-btn svg {
|
||
width: 18px !important;
|
||
height: 18px !important;
|
||
fill: currentColor !important;
|
||
}
|
||
|
||
.content-area {
|
||
padding: 0;
|
||
flex: 1 1 0;
|
||
background: var(--card-bg-color);
|
||
position: relative;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
min-height: 0;
|
||
/* Height will be computed dynamically by JS below */
|
||
}
|
||
.markmap-container {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: var(--card-bg-color);
|
||
}
|
||
.markmap-container svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
.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;
|
||
stroke-opacity: 0.6;
|
||
}
|
||
.theme-dark .markmap-node circle {
|
||
fill: var(--card-bg-color) !important;
|
||
}
|
||
.markmap-container svg .markmap-node circle,
|
||
.markmap-container svg .markmap-node rect {
|
||
stroke: var(--node-stroke-color) !important;
|
||
}
|
||
.control-rows {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-left: auto; /* Push controls to the right */
|
||
}
|
||
.btn-group {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 2px;
|
||
background: var(--background-color);
|
||
}
|
||
.control-btn {
|
||
background-color: transparent;
|
||
color: var(--text-color);
|
||
border: none;
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
font-size: 0.85em;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 28px;
|
||
box-sizing: border-box;
|
||
opacity: 0.8;
|
||
}
|
||
.control-btn:hover {
|
||
background-color: var(--card-bg-color);
|
||
opacity: 1;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}
|
||
.control-btn:active {
|
||
transform: translateY(1px);
|
||
}
|
||
.control-btn.primary {
|
||
background-color: var(--primary-color);
|
||
color: white;
|
||
opacity: 1;
|
||
}
|
||
.control-btn.primary:hover {
|
||
box-shadow: 0 2px 5px rgba(30,136,229,0.3);
|
||
}
|
||
|
||
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 option {
|
||
background-color: var(--card-bg-color);
|
||
color: var(--text-color);
|
||
}
|
||
.error-message {
|
||
color: #c62828;
|
||
background-color: #ffcdd2;
|
||
border: 1px solid #ef9a9a;
|
||
padding: 14px;
|
||
border-radius: 8px;
|
||
font-weight: 500;
|
||
font-size: 1em;
|
||
margin: 10px;
|
||
}
|
||
|
||
/* Mobile Responsive Adjustments */
|
||
@media screen and (max-width: 768px) {
|
||
.mindmap-container-wrapper {
|
||
min-height: 400px;
|
||
height: 80vh;
|
||
}
|
||
.header {
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.btn-group {
|
||
padding: 2px;
|
||
}
|
||
.control-btn {
|
||
padding: 4px 6px;
|
||
font-size: 0.75em;
|
||
height: 28px;
|
||
}
|
||
select.control-btn {
|
||
padding-right: 20px;
|
||
background-position: right 4px center;
|
||
}
|
||
}
|
||
"""
|
||
|
||
CONTENT_TEMPLATE_MINDMAP = """
|
||
<div class="mindmap-container-wrapper">
|
||
<div class="header">
|
||
<div class="header-top">
|
||
<h1>{t_ui_title}</h1>
|
||
<div class="header-credits">
|
||
<span>{t_ui_footer}</span>
|
||
</div>
|
||
<div class="control-rows">
|
||
<div class="btn-group">
|
||
<button id="download-png-btn-{unique_id}" class="control-btn primary" title="{t_ui_download_png}">PNG</button>
|
||
<button id="download-svg-btn-{unique_id}" class="control-btn" title="{t_ui_download_svg}">SVG</button>
|
||
<button id="download-md-btn-{unique_id}" class="control-btn" title="{t_ui_download_md}">MD</button>
|
||
<a href="https://github.com/Fu-Jie/openwebui-extensions" target="_blank" rel="noopener noreferrer" title="Star on GitHub" class="control-btn star-btn">
|
||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 18px; height: 18px;">
|
||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>
|
||
</svg>
|
||
</a>
|
||
</div>
|
||
<div class="btn-group">
|
||
<button id="zoom-out-btn-{unique_id}" class="control-btn" title="{t_ui_zoom_out}">-</button>
|
||
<button id="zoom-reset-btn-{unique_id}" class="control-btn" title="{t_ui_zoom_reset}">↺</button>
|
||
<button id="zoom-in-btn-{unique_id}" class="control-btn" title="{t_ui_zoom_in}">+</button>
|
||
</div>
|
||
<div class="btn-group">
|
||
<select id="depth-select-{unique_id}" class="control-btn" title="{t_ui_depth_select}">
|
||
<option value="0" selected>{t_ui_depth_all}</option>
|
||
<option value="2">{t_ui_depth_2}</option>
|
||
<option value="3">{t_ui_depth_3}</option>
|
||
</select>
|
||
<button id="fullscreen-btn-{unique_id}" class="control-btn" title="{t_ui_fullscreen}">⛶</button>
|
||
<button id="theme-toggle-btn-{unique_id}" class="control-btn" title="{t_ui_theme}">◑</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="content-area">
|
||
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||
"""
|
||
|
||
SCRIPT_TEMPLATE_MINDMAP = """
|
||
<script>
|
||
(function() {
|
||
const uniqueId = {unique_id_json};
|
||
const i18n = {i18n_json};
|
||
|
||
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;
|
||
};
|
||
|
||
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">' + i18n.html_error_missing_content + '</div>';
|
||
return;
|
||
}
|
||
|
||
ensureMarkmapReady().then(() => {
|
||
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svgEl.style.width = '100%';
|
||
svgEl.style.height = '100%';
|
||
containerEl.innerHTML = '';
|
||
containerEl.appendChild(svgEl);
|
||
|
||
const { Transformer, Markmap } = window.markmap;
|
||
const transformer = new Transformer();
|
||
const { root } = transformer.transform(markdownContent);
|
||
|
||
const containerWidth = containerEl.clientWidth || window.innerWidth;
|
||
const containerHeight = containerEl.clientHeight || window.innerHeight;
|
||
const isPortrait = containerHeight >= containerWidth * 0.8;
|
||
|
||
const style = (id) => `
|
||
${id} text, ${id} foreignObject { font-size: 16px; }
|
||
${id} foreignObject { line-height: 1.6; }
|
||
${id} foreignObject div { padding: 2px 0; }
|
||
${id} foreignObject h1 { font-size: 24px; font-weight: 700; margin: 0 0 6px 0; border-bottom: 2px solid currentColor; padding-bottom: 4px; display: inline-block; }
|
||
${id} foreignObject h2 { font-size: 18px; font-weight: 600; margin: 0 0 4px 0; }
|
||
${id} foreignObject strong { font-weight: 700; }
|
||
${id} foreignObject p { margin: 2px 0; }
|
||
`;
|
||
|
||
let responsiveMaxWidth;
|
||
let dynamicSpacingVertical = 5;
|
||
let dynamicSpacingHorizontal = 80;
|
||
|
||
if (isPortrait) {
|
||
// Old Version / Mobile: Force early text wrap to explode height and tighten width
|
||
responsiveMaxWidth = Math.max(140, Math.floor(containerWidth * 0.35));
|
||
dynamicSpacingVertical = 20; // Explicitly spread out branches vertically
|
||
dynamicSpacingHorizontal = 60;
|
||
} else {
|
||
// New Version (Direct Chat): Generous width to utilize massive horizontal space
|
||
responsiveMaxWidth = Math.max(220, Math.floor(containerWidth * 0.35));
|
||
dynamicSpacingVertical = 12;
|
||
dynamicSpacingHorizontal = 60; // Tighter horizontal gaps so the chart doesn't get too wide to scale up
|
||
}
|
||
|
||
const options = {
|
||
autoFit: true,
|
||
style: style,
|
||
initialExpandLevel: 3,
|
||
zoom: true,
|
||
pan: true,
|
||
fitRatio: 0.95, // Maximize scale to make text bigger
|
||
maxWidth: responsiveMaxWidth,
|
||
spacingVertical: dynamicSpacingVertical,
|
||
spacingHorizontal: dynamicSpacingHorizontal,
|
||
colorFreezeLevel: 2
|
||
};
|
||
|
||
const markmapInstance = Markmap.create(svgEl, options, root);
|
||
|
||
// Extra tick: force fit to make sure bounding box centers
|
||
setTimeout(() => {
|
||
markmapInstance.fit();
|
||
}, 100);
|
||
|
||
// Dynamically refit if the user drags to resize the sidebar/iframe
|
||
const resizeObserver = new ResizeObserver(entries => {
|
||
for (let entry of entries) {
|
||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||
requestAnimationFrame(() => markmapInstance.fit());
|
||
}
|
||
}
|
||
});
|
||
resizeObserver.observe(containerEl);
|
||
|
||
window.markmapInstance = markmapInstance; // Expose for external triggers
|
||
containerEl.dataset.markmapRendered = 'true';
|
||
|
||
setupControls({
|
||
containerEl,
|
||
svgEl,
|
||
markmapInstance,
|
||
root,
|
||
isPortrait
|
||
});
|
||
|
||
}).catch((error) => {
|
||
console.error('Markmap loading error:', error);
|
||
containerEl.innerHTML = '<div class="error-message">' + i18n.html_error_load_failed + '</div>';
|
||
});
|
||
};
|
||
|
||
// Dynamically fix layout: measure header height and set content-area height precisely
|
||
const adjustLayout = () => {
|
||
const wrapper = document.querySelector('.mindmap-container-wrapper');
|
||
const header = document.querySelector('.header');
|
||
const contentArea = document.querySelector('.content-area');
|
||
if (!wrapper || !header || !contentArea) return;
|
||
const headerH = header.getBoundingClientRect().height;
|
||
const totalH = wrapper.getBoundingClientRect().height;
|
||
const contentH = Math.max(totalH - headerH, 200);
|
||
contentArea.style.height = contentH + 'px';
|
||
};
|
||
|
||
// Run once after DOM is ready, then on any resize
|
||
adjustLayout();
|
||
window.addEventListener('resize', () => {
|
||
adjustLayout();
|
||
if (window.markmapInstance) {
|
||
requestAnimationFrame(() => window.markmapInstance.fit());
|
||
}
|
||
});
|
||
|
||
const setupControls = ({ containerEl, svgEl, markmapInstance, root, isPortrait }) => {
|
||
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);
|
||
|
||
if (depthSelect) {
|
||
depthSelect.value = "3";
|
||
}
|
||
|
||
const wrapper = containerEl.closest('.mindmap-container-wrapper');
|
||
let currentTheme = setTheme(wrapper);
|
||
|
||
const showFeedback = (button, textOk = i18n.js_done, textFail = i18n.js_failed) => {
|
||
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, i18n.js_failed, i18n.js_failed));
|
||
} 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, i18n.js_failed, i18n.js_failed);
|
||
}
|
||
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 btnTextEl = btn.querySelector('.btn-text') || btn;
|
||
const originalText = btnTextEl.textContent;
|
||
btnTextEl.textContent = i18n.js_generating;
|
||
btn.disabled = true;
|
||
|
||
const svg = containerEl.querySelector('svg');
|
||
if (!svg) {
|
||
btnTextEl.textContent = originalText;
|
||
btn.disabled = false;
|
||
showFeedback(btn, i18n.js_failed, i18n.js_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: 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) {
|
||
btnTextEl.textContent = originalText;
|
||
btn.disabled = false;
|
||
showFeedback(btn, i18n.js_failed, i18n.js_failed);
|
||
return;
|
||
}
|
||
|
||
// Use non-bubbling MouseEvent to avoid router interception
|
||
const a = document.createElement('a');
|
||
a.download = i18n.js_filename;
|
||
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);
|
||
|
||
btnTextEl.textContent = originalText;
|
||
btn.disabled = false;
|
||
showFeedback(btn);
|
||
}, 'image/png');
|
||
};
|
||
|
||
img.onerror = (e) => {
|
||
console.error('PNG image load error:', e);
|
||
btnTextEl.textContent = originalText;
|
||
btn.disabled = false;
|
||
showFeedback(btn, i18n.js_failed, i18n.js_failed);
|
||
};
|
||
|
||
img.src = dataUrl;
|
||
} catch (err) {
|
||
console.error('PNG export error:', err);
|
||
btnTextEl.textContent = originalText;
|
||
btn.disabled = false;
|
||
showFeedback(btn, i18n.js_failed, i18n.js_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 setExpandLevel = (levelStr) => {
|
||
const level = parseInt(levelStr, 10);
|
||
const expandLevel = level === 0 ? Infinity : level;
|
||
|
||
// Recursively set fold state on cloned tree nodes
|
||
const applyFold = (node, currentDepth) => {
|
||
if (!node) return;
|
||
if (!node.payload) node.payload = {};
|
||
if (expandLevel === Infinity) {
|
||
// Expand ALL: clear all fold flags
|
||
node.payload.fold = 0;
|
||
} else {
|
||
// Fold any node deeper than the target level
|
||
node.payload.fold = currentDepth >= expandLevel ? 1 : 0;
|
||
}
|
||
if (node.children) {
|
||
node.children.forEach(child => applyFold(child, currentDepth + 1));
|
||
}
|
||
};
|
||
|
||
const cleanRoot = JSON.parse(JSON.stringify(root));
|
||
applyFold(cleanRoot, 0);
|
||
|
||
markmapInstance.setOptions({ initialExpandLevel: expandLevel });
|
||
markmapInstance.setData(cleanRoot);
|
||
setTimeout(() => markmapInstance.fit(), 50);
|
||
};
|
||
|
||
const handleDepthChange = (e) => {
|
||
setExpandLevel(e.target.value);
|
||
};
|
||
|
||
const handleFullscreen = () => {
|
||
const el = wrapper || containerEl;
|
||
if (!document.fullscreenElement) {
|
||
el.requestFullscreen().then(() => {
|
||
if (depthSelect) depthSelect.value = "0";
|
||
setExpandLevel("0");
|
||
}).catch(err => {
|
||
console.error('Fullscreen failed:', err);
|
||
// Fallback to container if wrapper fails
|
||
containerEl.requestFullscreen().then(() => {
|
||
if (depthSelect) depthSelect.value = "0";
|
||
setExpandLevel("0");
|
||
});
|
||
});
|
||
} else {
|
||
document.exitFullscreen();
|
||
}
|
||
};
|
||
|
||
document.addEventListener('fullscreenchange', () => {
|
||
const isFs = !!document.fullscreenElement;
|
||
if (isFs && (document.fullscreenElement === containerEl || document.fullscreenElement === wrapper)) {
|
||
setTimeout(() => markmapInstance.fit(), 300);
|
||
} else if (!isFs) {
|
||
// Revert to default depth when exiting fullscreen
|
||
const defaultLevel = "3";
|
||
if (depthSelect) depthSelect.value = defaultLevel;
|
||
setExpandLevel(defaultLevel);
|
||
}
|
||
});
|
||
|
||
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="Whether to show action status updates in the chat interface.",
|
||
)
|
||
MODEL_ID: str = Field(
|
||
default="",
|
||
description="Built-in LLM model ID for text analysis. If empty, uses the current conversation's model.",
|
||
)
|
||
MIN_TEXT_LENGTH: int = Field(
|
||
default=100,
|
||
description="Minimum text length (character count) required for mind map analysis.",
|
||
)
|
||
CLEAR_PREVIOUS_HTML: bool = Field(
|
||
default=False,
|
||
description="Whether to force clear previous plugin results (if True, overwrites instead of merging).",
|
||
)
|
||
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.",
|
||
)
|
||
OUTPUT_MODE: str = Field(
|
||
default="html",
|
||
description="Output mode: 'html' for interactive HTML (default), or 'image' to embed as Markdown image.",
|
||
)
|
||
SHOW_DEBUG_LOG: bool = Field(
|
||
default=False,
|
||
description="Whether to print debug logs in the browser console.",
|
||
)
|
||
ENABLE_DIRECT_EMBED_MODE: bool = Field(
|
||
default=True,
|
||
description="Enable Direct Embed Mode (v0.8.0+ layout) instead of Legacy Mode. Defaults to True (Direct Embed Mode).",
|
||
)
|
||
|
||
def __init__(self):
|
||
self.valves = self.Valves()
|
||
self.weekday_map = {
|
||
"Monday": "Monday",
|
||
"Tuesday": "Tuesday",
|
||
"Wednesday": "Wednesday",
|
||
"Thursday": "Thursday",
|
||
"Friday": "Friday",
|
||
"Saturday": "Saturday",
|
||
"Sunday": "Sunday",
|
||
}
|
||
# Fallback mapping for variants not in TRANSLATIONS keys
|
||
self.fallback_map = {
|
||
"es-AR": "es-ES",
|
||
"es-MX": "es-ES",
|
||
"fr-CA": "fr-FR",
|
||
"en-CA": "en-US",
|
||
"en-GB": "en-US",
|
||
"en-AU": "en-US",
|
||
"de-AT": "de-DE",
|
||
}
|
||
|
||
def _resolve_language(self, lang: str) -> str:
|
||
"""Resolve the best matching language code from the TRANSLATIONS dict."""
|
||
target_lang = lang
|
||
|
||
# 1. Direct match
|
||
if target_lang in TRANSLATIONS:
|
||
return target_lang
|
||
|
||
# 2. Variant fallback (explicit mapping)
|
||
if target_lang in self.fallback_map:
|
||
target_lang = self.fallback_map[target_lang]
|
||
if target_lang in TRANSLATIONS:
|
||
return target_lang
|
||
|
||
# 3. Base language fallback (e.g. fr-BE -> fr-FR)
|
||
# Check if the base language (part before -) exists in translations
|
||
if "-" in lang:
|
||
base_lang = lang.split("-")[0]
|
||
# Try to find a supported language starting with base_lang
|
||
# Prioritize standard variants (e.g., fr -> fr-FR)
|
||
for supported_lang in TRANSLATIONS:
|
||
if supported_lang.startswith(base_lang + "-"):
|
||
return supported_lang
|
||
|
||
# 4. Final Fallback to en-US
|
||
return "en-US"
|
||
|
||
def _get_translation(self, lang: str, key: str, **kwargs) -> str:
|
||
"""Get translated string for the given language and key."""
|
||
target_lang = self._resolve_language(lang)
|
||
|
||
# Retrieve dictionary
|
||
lang_dict = TRANSLATIONS.get(target_lang, TRANSLATIONS["en-US"])
|
||
|
||
# Get string
|
||
text = lang_dict.get(key, TRANSLATIONS["en-US"].get(key, key))
|
||
|
||
# Format if arguments provided
|
||
if kwargs:
|
||
try:
|
||
text = text.format(**kwargs)
|
||
except Exception as e:
|
||
logger.warning(f"Translation formatting failed for {key}: {e}")
|
||
|
||
return text
|
||
|
||
async def _get_user_context(
|
||
self,
|
||
__user__: Optional[Dict[str, Any]],
|
||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||
__request__: Optional[Request] = None,
|
||
) -> 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 = {}
|
||
|
||
user_id = user_data.get("id", "unknown_user")
|
||
user_name = user_data.get("name", "User")
|
||
# Default from profile
|
||
user_language = user_data.get("language", "en-US")
|
||
|
||
# Level 1 Fallback: Accept-Language from __request__ headers
|
||
if (
|
||
__request__
|
||
and hasattr(__request__, "headers")
|
||
and "accept-language" in __request__.headers
|
||
):
|
||
raw_lang = __request__.headers.get("accept-language", "")
|
||
if raw_lang:
|
||
user_language = raw_lang.split(",")[0].split(";")[0]
|
||
|
||
# Priority: Document Lang > LocalStorage (Frontend) > Browser > Request Header > Profile
|
||
if __event_call__:
|
||
try:
|
||
js_code = """
|
||
try {
|
||
return (
|
||
document.documentElement.lang ||
|
||
localStorage.getItem('locale') ||
|
||
localStorage.getItem('language') ||
|
||
navigator.language ||
|
||
'en-US'
|
||
);
|
||
} catch (e) {
|
||
return 'en-US';
|
||
}
|
||
"""
|
||
# Use asyncio.wait_for to prevent hanging if frontend fails to callback
|
||
frontend_lang = await asyncio.wait_for(
|
||
__event_call__({"type": "execute", "data": {"code": js_code}}),
|
||
timeout=2.0,
|
||
)
|
||
if frontend_lang and isinstance(frontend_lang, str):
|
||
user_language = frontend_lang
|
||
except Exception as e:
|
||
logger.warning(f"Failed to retrieve frontend language: {e}")
|
||
|
||
return {
|
||
"user_id": user_id,
|
||
"user_name": user_name,
|
||
"user_language": user_language,
|
||
}
|
||
|
||
def _get_chat_context(
|
||
self, body: dict, __metadata__: Optional[dict] = None
|
||
) -> Dict[str, str]:
|
||
"""
|
||
Unified extraction of chat context information (chat_id, message_id).
|
||
Prioritizes extraction from body, then metadata.
|
||
"""
|
||
chat_id = ""
|
||
message_id = ""
|
||
|
||
# 1. Try to get from body
|
||
if isinstance(body, dict):
|
||
chat_id = body.get("chat_id", "")
|
||
message_id = body.get("id", "") # message_id is usually 'id' in body
|
||
|
||
# Check body.metadata as fallback
|
||
if not chat_id or not message_id:
|
||
body_metadata = body.get("metadata", {})
|
||
if isinstance(body_metadata, dict):
|
||
if not chat_id:
|
||
chat_id = body_metadata.get("chat_id", "")
|
||
if not message_id:
|
||
message_id = body_metadata.get("message_id", "")
|
||
|
||
# 2. Try to get from __metadata__ (as supplement)
|
||
if __metadata__ and isinstance(__metadata__, dict):
|
||
if not chat_id:
|
||
chat_id = __metadata__.get("chat_id", "")
|
||
if not message_id:
|
||
message_id = __metadata__.get("message_id", "")
|
||
|
||
return {
|
||
"chat_id": str(chat_id).strip(),
|
||
"message_id": str(message_id).strip(),
|
||
}
|
||
|
||
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>")
|
||
|
||
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}}
|
||
)
|
||
|
||
async def _emit_debug_log(self, emitter, title: str, data: dict):
|
||
"""Print structured debug logs in the browser console"""
|
||
if not self.valves.SHOW_DEBUG_LOG or not emitter:
|
||
return
|
||
|
||
try:
|
||
js_code = f"""
|
||
(async function() {{
|
||
console.group("🛠️ {title}");
|
||
console.log({json.dumps(data, ensure_ascii=False)});
|
||
console.groupEnd();
|
||
}})();
|
||
"""
|
||
|
||
await emitter({"type": "execute", "data": {"code": js_code}})
|
||
except Exception as e:
|
||
print(f"Error emitting debug log: {e}")
|
||
|
||
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()
|
||
|
||
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 ""
|
||
|
||
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.
|
||
"""
|
||
# Security: Escape user_language to prevent XSS
|
||
safe_language = user_language.replace('"', """)
|
||
|
||
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("{lang}", safe_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,
|
||
lang: str,
|
||
) -> 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>")
|
||
)
|
||
|
||
# Prepare i18n for this specific context
|
||
i18n_data = {}
|
||
target_lang = lang
|
||
if target_lang not in TRANSLATIONS and target_lang in self.fallback_map:
|
||
target_lang = self.fallback_map[target_lang]
|
||
if target_lang not in TRANSLATIONS:
|
||
target_lang = "en-US"
|
||
|
||
full_trans = TRANSLATIONS.get(target_lang, TRANSLATIONS["en-US"])
|
||
# We only need specific keys for the JS image generation part
|
||
keys = ["js_upload_failed", "md_image_alt"]
|
||
for k in keys:
|
||
i18n_data[k] = full_trans.get(k, TRANSLATIONS["en-US"].get(k, k))
|
||
|
||
i18n_json = json.dumps(i18n_data, ensure_ascii=False)
|
||
|
||
return f"""
|
||
(async function() {{
|
||
const uniqueId = "{unique_id}";
|
||
const chatId = "{chat_id}";
|
||
const messageId = "{message_id}";
|
||
const i18n = {i18n_json};
|
||
const defaultWidth = 1200;
|
||
|
||
// Theme detection - check parent document for OpenWebUI theme
|
||
const detectTheme = () => {{
|
||
try {{
|
||
// 1. Check parent document's html/body class or data-theme
|
||
const html = document.documentElement;
|
||
const body = document.body;
|
||
const htmlClass = html ? html.className : '';
|
||
const bodyClass = body ? body.className : '';
|
||
const htmlDataTheme = html ? html.getAttribute('data-theme') : '';
|
||
|
||
if (htmlDataTheme === 'dark' || bodyClass.includes('dark') || htmlClass.includes('dark')) {{
|
||
return 'dark';
|
||
}}
|
||
if (htmlDataTheme === 'light' || bodyClass.includes('light') || htmlClass.includes('light')) {{
|
||
return 'light';
|
||
}}
|
||
|
||
// 2. Check meta theme-color
|
||
const metas = document.querySelectorAll('meta[name="theme-color"]');
|
||
if (metas.length > 0) {{
|
||
const color = metas[metas.length - 1].content.trim();
|
||
const m = color.match(/^#?([0-9a-f]{{6}})$/i);
|
||
if (m) {{
|
||
const hex = m[1];
|
||
const r = parseInt(hex.slice(0, 2), 16);
|
||
const g = parseInt(hex.slice(2, 4), 16);
|
||
const b = parseInt(hex.slice(4, 6), 16);
|
||
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||
return luma < 0.5 ? 'dark' : 'light';
|
||
}}
|
||
}}
|
||
|
||
// 3. Check system preference
|
||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
|
||
return 'dark';
|
||
}}
|
||
|
||
return 'light';
|
||
}} catch (e) {{
|
||
return 'light';
|
||
}}
|
||
}};
|
||
|
||
const currentTheme = detectTheme();
|
||
console.log("[MindMap Image] Detected theme:", currentTheme);
|
||
|
||
// Theme-based colors
|
||
const colors = currentTheme === 'dark' ? {{
|
||
background: '#1f2937',
|
||
text: '#e5e7eb',
|
||
link: '#94a3b8',
|
||
nodeStroke: '#94a3b8'
|
||
}} : {{
|
||
background: '#ffffff',
|
||
text: '#1f2937',
|
||
link: '#546e7a',
|
||
nodeStroke: '#94a3b8'
|
||
}};
|
||
|
||
// Auto-detect chat container width for responsive sizing
|
||
let svgWidth = defaultWidth;
|
||
// Initial height placeholder, will be adjusted by fit()
|
||
let svgHeight = 600;
|
||
|
||
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);
|
||
}}
|
||
}}
|
||
|
||
try {{
|
||
// Load D3 if not loaded
|
||
if (typeof d3 === 'undefined') {{
|
||
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) {{
|
||
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) {{
|
||
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}`;
|
||
|
||
// Create offscreen container
|
||
const container = document.createElement('div');
|
||
container.id = 'mindmap-offscreen-' + uniqueId;
|
||
// Start with a reasonably large height to allow layout, but we'll crop it later
|
||
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:' + svgWidth + 'px;height:2000px;overflow:hidden;';
|
||
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', '2000'); // Initial large height
|
||
svgEl.style.width = svgWidth + 'px';
|
||
svgEl.style.height = '2000px';
|
||
svgEl.style.backgroundColor = colors.background;
|
||
container.appendChild(svgEl);
|
||
|
||
// Transform markdown to tree
|
||
const transformer = new Transformer();
|
||
const {{ root }} = transformer.transform(syntaxContent);
|
||
|
||
// Create markmap instance
|
||
const options = {{
|
||
autoFit: false, // We will manually fit and measure
|
||
initialExpandLevel: Infinity,
|
||
zoom: false,
|
||
pan: false,
|
||
maxWidth: 280
|
||
}};
|
||
|
||
const markmapInstance = Markmap.create(svgEl, options, root);
|
||
|
||
// Wait for render to complete
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Fit to content to get bounds
|
||
markmapInstance.fit();
|
||
|
||
// Calculate actual content height based on the graph state
|
||
// Markmap D3 logic: minY, maxY are stored in state or we can measure the group
|
||
let minY = Infinity;
|
||
let maxY = -Infinity;
|
||
|
||
// Inspect D3 nodes to find bounding box
|
||
const nodes = svgEl.querySelectorAll('g.markmap-node');
|
||
if (nodes.length > 0) {{
|
||
// This is a rough estimation. Better to use D3's getBBox if possible
|
||
// But we are in an isolated context.
|
||
// Let's try to get the main group transform which markmap sets for zoom/pan
|
||
const g = svgEl.querySelector('g');
|
||
if (g) {{
|
||
const bbox = g.getBBox();
|
||
// Markmap applies a transform to 'g' to center it.
|
||
// We want to adjust the SVG height to match this bbox height + padding
|
||
// And re-center.
|
||
|
||
// Add some padding
|
||
const padding = 20;
|
||
const contentHeight = bbox.height + (padding * 2);
|
||
const contentWidth = bbox.width + (padding * 2);
|
||
|
||
// Update SVG height to fit content exactly
|
||
svgHeight = Math.ceil(contentHeight);
|
||
|
||
// Ensure minimum height
|
||
if (svgHeight < 300) svgHeight = 300;
|
||
|
||
svgEl.setAttribute('height', svgHeight);
|
||
svgEl.style.height = svgHeight + 'px';
|
||
|
||
// Re-fit with new dimensions
|
||
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');
|
||
|
||
// Explicitly set the final width/height on the cloned SVG
|
||
clonedSvg.setAttribute('width', svgWidth);
|
||
clonedSvg.setAttribute('height', svgHeight);
|
||
|
||
// Add background rect with theme color
|
||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||
bgRect.setAttribute('width', '100%');
|
||
bgRect.setAttribute('height', '100%');
|
||
bgRect.setAttribute('fill', colors.background);
|
||
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
||
|
||
// Add inline styles with theme colors
|
||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||
style.textContent = `
|
||
text {{ font-family: sans-serif; font-size: 14px; fill: ${{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);
|
||
|
||
// 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', 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);
|
||
}});
|
||
|
||
// Serialize SVG to string
|
||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||
|
||
// Cleanup container
|
||
document.body.removeChild(container);
|
||
|
||
// Convert SVG string to Blob
|
||
const blob = new Blob([svgData], {{ type: 'image/svg+xml' }});
|
||
const file = new File([blob], `mindmap-${{uniqueId}}.svg`, {{ type: 'image/svg+xml' }});
|
||
|
||
// Upload file to OpenWebUI API
|
||
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(i18n.js_upload_failed + uploadResponse.statusText);
|
||
}}
|
||
|
||
const fileData = await uploadResponse.json();
|
||
const fileId = fileData.id;
|
||
const imageUrl = `/api/v1/files/${{fileId}}/content`;
|
||
|
||
const markdownImage = ``;
|
||
|
||
// Update message via API
|
||
if (chatId && messageId) {{
|
||
|
||
// Helper function with retry logic
|
||
const fetchWithRetry = async (url, options, retries = 3) => {{
|
||
for (let i = 0; i < retries; i++) {{
|
||
try {{
|
||
const response = await fetch(url, options);
|
||
if (response.ok) return response;
|
||
if (i < retries - 1) {{
|
||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||
}}
|
||
}} catch (e) {{
|
||
if (i === retries - 1) throw e;
|
||
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
||
}}
|
||
}}
|
||
return null;
|
||
}};
|
||
|
||
// Get current chat data
|
||
const getResponse = await fetch(`/api/v1/chats/${{chatId}}`, {{
|
||
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 updatedMessages = [];
|
||
let newContent = "";
|
||
|
||
if (chatData.chat && chatData.chat.messages) {{
|
||
updatedMessages = chatData.chat.messages.map(m => {{
|
||
if (m.id === messageId) {{
|
||
const originalContent = m.content || "";
|
||
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;
|
||
|
||
// Critical: Update content in both messages array AND history object
|
||
// The history object is the source of truth for the database
|
||
if (chatData.chat.history && chatData.chat.history.messages) {{
|
||
if (chatData.chat.history.messages[messageId]) {{
|
||
chatData.chat.history.messages[messageId].content = newContent;
|
||
}}
|
||
}}
|
||
|
||
return {{ ...m, content: newContent }};
|
||
}}
|
||
return m;
|
||
}});
|
||
}}
|
||
|
||
if (!newContent) {{
|
||
return;
|
||
}}
|
||
|
||
// Try to update frontend display via event API (optional)
|
||
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) {{
|
||
}}
|
||
|
||
// Persist to database
|
||
const updatePayload = {{
|
||
chat: {{
|
||
...chatData.chat,
|
||
messages: updatedMessages
|
||
}}
|
||
}};
|
||
|
||
await fetchWithRetry(`/api/v1/chats/${{chatId}}`, {{
|
||
method: "POST",
|
||
headers: {{
|
||
"Content-Type": "application/json",
|
||
"Authorization": `Bearer ${{token}}`
|
||
}},
|
||
body: JSON.stringify(updatePayload)
|
||
}});
|
||
}}
|
||
|
||
}} catch (error) {{
|
||
console.error("[MindMap Image] Error:", error);
|
||
}}
|
||
}})();
|
||
"""
|
||
|
||
CSS_TEMPLATE_MINDMAP_DIRECT = """
|
||
: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 4px 12px rgba(0, 0, 0, 0.05);
|
||
--border-radius: 0;
|
||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
}
|
||
.theme-dark {
|
||
--primary-color: #3b82f6; /* High contrast blue */
|
||
--secondary-color: #22c55e; /* High contrast green */
|
||
--background-color: #0d1117; /* Deep background */
|
||
--card-bg-color: #161b22; /* Header background */
|
||
--text-color: #ffffff; /* Pure white text for max contrast */
|
||
--link-color: #58a6ff;
|
||
--node-stroke-color: #8b949e; /* Brighter node lines */
|
||
--muted-text-color: #7d8590;
|
||
--border-color: #30363d;
|
||
--header-gradient: linear-gradient(135deg, #1e88e5, #43a047);
|
||
--shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||
/* Legacy Control Styles */
|
||
--legacy-header-gradient: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||
}
|
||
html, body {
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
body {
|
||
font-family: var(--font-family);
|
||
background-color: transparent;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.mindmap-container-wrapper {
|
||
font-family: var(--font-family);
|
||
line-height: 1.5;
|
||
color: var(--text-color);
|
||
margin: 0;
|
||
padding: 0;
|
||
width: 100%;
|
||
height: clamp(600px, 85vh, 1400px); /* Canvas area even larger */
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--background-color);
|
||
position: relative;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
.header {
|
||
background: var(--card-bg-color);
|
||
color: var(--text-color);
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
border-bottom: 1px solid var(--border-color);
|
||
z-index: 10;
|
||
}
|
||
.header-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.header h1 {
|
||
margin: 0;
|
||
font-size: 1.2em;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.header-credits {
|
||
font-size: 0.8em;
|
||
color: var(--muted-text-color);
|
||
opacity: 0.8;
|
||
white-space: nowrap;
|
||
}
|
||
.header-credits a {
|
||
color: var(--primary-color);
|
||
text-decoration: none;
|
||
border-bottom: 1px dotted var(--link-color);
|
||
}
|
||
.content-area {
|
||
flex-grow: 1;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: var(--card-bg-color);
|
||
min-height: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.markmap-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
}
|
||
.markmap-container svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
.markmap-container svg text {
|
||
fill: var(--text-color) !important;
|
||
font-family: var(--font-family);
|
||
}
|
||
/* Force override all text containers within markmap */
|
||
.markmap-container foreignObject,
|
||
.markmap-container .markmap-foreign_object,
|
||
.markmap-container .markmap-node-label,
|
||
.markmap-container div {
|
||
color: var(--text-color) !important;
|
||
fill: var(--text-color) !important;
|
||
}
|
||
/* Optimize branch line colors for dark mode */
|
||
.theme-dark .markmap-link {
|
||
stroke-opacity: 0.6;
|
||
}
|
||
.theme-dark .markmap-node circle {
|
||
fill: var(--card-bg-color) !important;
|
||
}
|
||
/* Controls */
|
||
.control-rows {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-left: auto; /* Push controls to the right */
|
||
}
|
||
.btn-group {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 2px;
|
||
background: var(--background-color);
|
||
}
|
||
.control-btn {
|
||
background-color: transparent;
|
||
color: var(--text-color);
|
||
border: none;
|
||
padding: 4px 10px;
|
||
border-radius: 4px;
|
||
font-size: 0.85em;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 28px;
|
||
box-sizing: border-box;
|
||
opacity: 0.8;
|
||
}
|
||
.control-btn:hover {
|
||
background-color: var(--card-bg-color);
|
||
opacity: 1;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}
|
||
.control-btn:active {
|
||
transform: translateY(1px);
|
||
}
|
||
.control-btn.primary {
|
||
background-color: var(--primary-color);
|
||
color: white;
|
||
opacity: 1;
|
||
}
|
||
.control-btn.primary:hover {
|
||
box-shadow: 0 2px 5px rgba(30,136,229,0.3);
|
||
}
|
||
|
||
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 option {
|
||
background-color: var(--card-bg-color);
|
||
color: var(--text-color);
|
||
}
|
||
.error-message {
|
||
color: #d32f2f;
|
||
background-color: #ffebee;
|
||
padding: 20px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #ffcdd2;
|
||
}
|
||
|
||
/* Mobile Responsive Adjustments */
|
||
@media screen and (max-width: 768px) {
|
||
.mindmap-container-wrapper {
|
||
min-height: 480px;
|
||
height: 75vh;
|
||
}
|
||
.header {
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.btn-group {
|
||
padding: 2px;
|
||
}
|
||
.control-btn {
|
||
padding: 4px 6px;
|
||
font-size: 0.75em;
|
||
height: 28px;
|
||
}
|
||
select.control-btn {
|
||
padding-right: 20px;
|
||
background-position: right 4px center;
|
||
}
|
||
}
|
||
"""
|
||
|
||
CONTENT_TEMPLATE_MINDMAP_DIRECT = """
|
||
<div class="mindmap-container-wrapper">
|
||
<div class="header">
|
||
<div class="header-top">
|
||
<h1>{t_ui_title}</h1>
|
||
<div class="header-credits">
|
||
<span>{t_ui_footer}</span>
|
||
</div>
|
||
<div class="control-rows">
|
||
<div class="btn-group">
|
||
<button id="download-png-btn-{unique_id}" class="control-btn primary" title="{t_ui_download_png}">PNG</button>
|
||
<button id="download-svg-btn-{unique_id}" class="control-btn" title="{t_ui_download_svg}">SVG</button>
|
||
<button id="download-md-btn-{unique_id}" class="control-btn" title="{t_ui_download_md}">MD</button>
|
||
<a href="https://github.com/Fu-Jie/openwebui-extensions" target="_blank" rel="noopener noreferrer" title="Star on GitHub" class="control-btn star-btn">
|
||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 18px; height: 18px;">
|
||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>
|
||
</svg>
|
||
</a>
|
||
</div>
|
||
<div class="btn-group">
|
||
<button id="zoom-out-btn-{unique_id}" class="control-btn" title="{t_ui_zoom_out}">-</button>
|
||
<button id="zoom-reset-btn-{unique_id}" class="control-btn" title="{t_ui_zoom_reset}">↺</button>
|
||
<button id="zoom-in-btn-{unique_id}" class="control-btn" title="{t_ui_zoom_in}">+</button>
|
||
</div>
|
||
<div class="btn-group">
|
||
<select id="depth-select-{unique_id}" class="control-btn" title="{t_ui_depth_select}">
|
||
<option value="0" selected>{t_ui_depth_all}</option>
|
||
<option value="2">{t_ui_depth_2}</option>
|
||
<option value="3">{t_ui_depth_3}</option>
|
||
</select>
|
||
<button id="fullscreen-btn-{unique_id}" class="control-btn" title="{t_ui_fullscreen}">⛶</button>
|
||
<button id="theme-toggle-btn-{unique_id}" class="control-btn" title="{t_ui_theme}">◑</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="content-area">
|
||
<div class="markmap-container" id="markmap-container-{unique_id}"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="text/template" id="markdown-source-{unique_id}">{markdown_syntax}</script>
|
||
"""
|
||
|
||
async def action(
|
||
self,
|
||
body: dict,
|
||
__user__: Optional[Dict[str, Any]] = None,
|
||
__event_emitter__: Optional[Any] = None,
|
||
__event_call__: Optional[Callable[[Any], Awaitable[None]]] = None,
|
||
__metadata__: Optional[dict] = None,
|
||
__request__: Optional[Request] = None,
|
||
) -> Optional[dict]:
|
||
logger.info("Action: Smart Mind Map (v1.0.0) started")
|
||
user_ctx = await self._get_user_context(__user__, __event_call__, __request__)
|
||
user_language = user_ctx["user_language"]
|
||
user_name = user_ctx["user_name"]
|
||
user_id = user_ctx["user_id"]
|
||
long_text_content = "" # Initialize for exception handler safety
|
||
|
||
try:
|
||
tz_env = os.environ.get("TZ")
|
||
tzinfo = ZoneInfo(tz_env) if tz_env else None
|
||
now_dt = datetime.now(tzinfo or timezone.utc)
|
||
|
||
# Format current date time string for LLM parsing
|
||
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, "Unknown")
|
||
|
||
current_year = now_dt.strftime("%Y")
|
||
current_timezone_str = tz_env or "UTC"
|
||
except Exception as e:
|
||
logger.warning(f"Failed to get timezone info: {e}, using default values.")
|
||
now = datetime.now()
|
||
current_date_time_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
current_weekday_zh = "Unknown"
|
||
current_year = now.strftime("%Y")
|
||
current_timezone_str = "Unknown"
|
||
|
||
await self._emit_notification(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_starting"),
|
||
"info",
|
||
)
|
||
|
||
messages = body.get("messages")
|
||
if not messages or not isinstance(messages, list):
|
||
error_message = self._get_translation(user_language, "error_no_content")
|
||
await self._emit_notification(__event_emitter__, error_message, "error")
|
||
body["messages"].append(
|
||
{"role": "assistant", "content": f"❌ {error_message}"}
|
||
)
|
||
return body
|
||
|
||
# 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:
|
||
aggregated_parts.append(f"{text_content}")
|
||
|
||
if not aggregated_parts:
|
||
error_message = self._get_translation(user_language, "error_no_content")
|
||
await self._emit_notification(__event_emitter__, error_message, "error")
|
||
body["messages"].append(
|
||
{"role": "assistant", "content": f"❌ {error_message}"}
|
||
)
|
||
return body
|
||
|
||
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 = self._get_translation(
|
||
user_language,
|
||
"error_text_too_short",
|
||
len=len(long_text_content),
|
||
min_len=self.valves.MIN_TEXT_LENGTH,
|
||
)
|
||
await self._emit_notification(
|
||
__event_emitter__, short_text_message, "warning"
|
||
)
|
||
body["messages"].append(
|
||
{"role": "assistant", "content": f"⚠️ {short_text_message}"}
|
||
)
|
||
return body
|
||
|
||
await self._emit_notification(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_analyzing"),
|
||
"info",
|
||
)
|
||
|
||
await self._emit_status(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_analyzing"),
|
||
False,
|
||
)
|
||
|
||
try:
|
||
unique_id = f"id_{int(time.time() * 1000)}"
|
||
|
||
# Prepare LLM request
|
||
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,
|
||
)
|
||
|
||
# Determine model to use
|
||
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},
|
||
],
|
||
"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"
|
||
]
|
||
logger.info(f"LLM Response length: {len(assistant_response_content)}")
|
||
if self.valves.SHOW_DEBUG_LOG:
|
||
logger.info(
|
||
f"LLM Response content: {assistant_response_content[:500]}..."
|
||
)
|
||
markdown_syntax = self._extract_markdown_syntax(assistant_response_content)
|
||
|
||
# Prepare content components
|
||
# Resolve translations for UI
|
||
ui_trans = {}
|
||
# Iterate over base language keys to ensure no missing placeholders
|
||
for k in TRANSLATIONS["en-US"]:
|
||
if k.startswith("ui_"):
|
||
val = self._get_translation(user_language, k)
|
||
if k == "ui_footer":
|
||
ui_trans[f"t_{k}"] = val.format(year=current_year)
|
||
else:
|
||
ui_trans[f"t_{k}"] = val
|
||
|
||
# Security: Use simple string replacement instead of format() to prevent
|
||
# crashes if markdown_syntax contains braces { or }.
|
||
# Also escape user_name for basic HTML safety.
|
||
content_html = CONTENT_TEMPLATE_MINDMAP
|
||
for k, v in ui_trans.items():
|
||
content_html = content_html.replace(f"{{{k}}}", v)
|
||
|
||
content_html = content_html.replace("{unique_id}", unique_id).replace(
|
||
"{markdown_syntax}", markdown_syntax
|
||
)
|
||
|
||
# Prepare JS i18n
|
||
target_lang = self._resolve_language(user_language)
|
||
full_trans = TRANSLATIONS.get(target_lang, TRANSLATIONS["en-US"])
|
||
js_trans = {}
|
||
for k in full_trans:
|
||
if k.startswith("js_") or k.startswith("html_"):
|
||
js_trans[k] = full_trans[k]
|
||
|
||
i18n_json = json.dumps(js_trans, ensure_ascii=False)
|
||
unique_id_json = json.dumps(unique_id)
|
||
|
||
# Note: We don't need chat/message ID in HTML mode JS, but we do need uniqueId and i18n
|
||
# The SCRIPT_TEMPLATE_MINDMAP now uses {unique_id_json} for the ID
|
||
script_html = (
|
||
SCRIPT_TEMPLATE_MINDMAP.replace(
|
||
"{unique_id}",
|
||
unique_id, # Fallback for other non-JSON placeholders if any
|
||
)
|
||
.replace("{unique_id_json}", unique_id_json)
|
||
.replace("{i18n_json}", i18n_json)
|
||
)
|
||
|
||
# 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,
|
||
)
|
||
|
||
# Check output mode
|
||
if self.valves.OUTPUT_MODE == "image":
|
||
# Image mode: use JavaScript to render and embed as Markdown image
|
||
chat_ctx = self._get_chat_context(body, __metadata__)
|
||
chat_id = chat_ctx["chat_id"]
|
||
message_id = chat_ctx["message_id"]
|
||
|
||
await self._emit_status(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_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,
|
||
lang=user_language,
|
||
)
|
||
|
||
await __event_call__(
|
||
{
|
||
"type": "execute",
|
||
"data": {"code": js_code},
|
||
}
|
||
)
|
||
|
||
await self._emit_status(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_image_generated"),
|
||
True,
|
||
)
|
||
await self._emit_notification(
|
||
__event_emitter__,
|
||
self._get_translation(
|
||
user_language, "notification_image_success", user_name=user_name
|
||
),
|
||
"success",
|
||
)
|
||
logger.info("Action: Smart Mind Map (v1.0.0) completed in image mode")
|
||
return body
|
||
|
||
# HTML mode
|
||
is_direct_mode = self._is_direct_html_supported(body)
|
||
|
||
if is_direct_mode:
|
||
# DIRECT EMBED MODE
|
||
# Use new templates
|
||
content_html_direct = self.CONTENT_TEMPLATE_MINDMAP_DIRECT
|
||
for k, v in ui_trans.items():
|
||
content_html_direct = content_html_direct.replace(f"{{{k}}}", v)
|
||
|
||
content_html_direct = (
|
||
content_html_direct.replace("{unique_id}", unique_id)
|
||
.replace(
|
||
"{user_name}",
|
||
user_name.replace("<", "<").replace(">", ">"),
|
||
)
|
||
.replace("{current_date_time_str}", current_date_time_str)
|
||
.replace("{markdown_syntax}", markdown_syntax)
|
||
)
|
||
|
||
# Script injection remains similar but tailored if needed
|
||
script_html_direct = (
|
||
SCRIPT_TEMPLATE_MINDMAP.replace("{unique_id}", unique_id)
|
||
.replace("{unique_id_json}", unique_id_json)
|
||
.replace("{i18n_json}", i18n_json)
|
||
)
|
||
|
||
# We do NOT wrap in <html> body for Direct Mode if using standard return
|
||
# But we still need styles.
|
||
# We can prepend styles to the div or return a full html doc?
|
||
# The requirements say: `return (html_content, ...)`
|
||
# Usually standard Action returns full HTML or fragments.
|
||
# If "inline", fragments are better, but styles need to be scoped or global.
|
||
# Our CSS templates use specific classes, should be safe.
|
||
# But to ensure it renders correctly, we usually wrap in a div.
|
||
|
||
final_html_direct = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<style>
|
||
{self.CSS_TEMPLATE_MINDMAP_DIRECT}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{content_html_direct}
|
||
{script_html_direct}
|
||
<script>
|
||
// Extra fit insurance for Direct Mode
|
||
const triggerFit = () => {{
|
||
const svg = document.querySelector('svg');
|
||
if (svg && window.markmapInstance) {{
|
||
window.markmapInstance.fit();
|
||
}}
|
||
}};
|
||
window.addEventListener('load', () => {{
|
||
triggerFit();
|
||
setTimeout(triggerFit, 300);
|
||
setTimeout(triggerFit, 800);
|
||
}});
|
||
// Also trigger on resize
|
||
window.addEventListener('resize', triggerFit);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
await self._emit_status(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_drawing"),
|
||
True,
|
||
)
|
||
await self._emit_notification(
|
||
__event_emitter__,
|
||
self._get_translation(
|
||
user_language, "notification_success", user_name=user_name
|
||
),
|
||
"success",
|
||
)
|
||
logger.info("Action: Smart Mind Map (v1.0.0) completed in Direct Mode")
|
||
|
||
return (
|
||
final_html_direct,
|
||
{"Content-Disposition": "inline", "Content-Type": "text/html"},
|
||
)
|
||
|
||
else:
|
||
# LEGACY MODE
|
||
# embed as HTML block into the message content
|
||
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__,
|
||
self._get_translation(user_language, "status_drawing"),
|
||
True,
|
||
)
|
||
await self._emit_notification(
|
||
__event_emitter__,
|
||
self._get_translation(
|
||
user_language, "notification_success", user_name=user_name
|
||
),
|
||
"success",
|
||
)
|
||
logger.info(
|
||
"Action: Smart Mind Map (v1.0.0) completed in Legacy HTML mode"
|
||
)
|
||
|
||
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 = self._get_translation(
|
||
user_language, "error_user_facing", error=str(e)
|
||
)
|
||
|
||
body["messages"][-1][
|
||
"content"
|
||
] = f"{long_text_content}\n\n❌ **Error:** {user_facing_error}"
|
||
|
||
await self._emit_status(
|
||
__event_emitter__,
|
||
self._get_translation(user_language, "status_failed"),
|
||
True,
|
||
)
|
||
await self._emit_notification(
|
||
__event_emitter__,
|
||
self._get_translation(
|
||
user_language, "notification_failed", user_name=user_name
|
||
),
|
||
"error",
|
||
)
|
||
return body
|
||
|
||
def _is_direct_html_supported(self, body: dict) -> bool:
|
||
"""
|
||
Check if the current Open WebUI version supports direct HTML return + inline display.
|
||
Target version >= 0.8.0.
|
||
"""
|
||
if not self.valves.ENABLE_DIRECT_EMBED_MODE:
|
||
return False
|
||
|
||
try:
|
||
# First check server-side version
|
||
version = open_webui_version
|
||
if not version or version == "0.0.0":
|
||
# If server version unknown, fallback to body version
|
||
version = body.get("version")
|
||
|
||
if not version:
|
||
# If still no version, default to True (assume modern)
|
||
return True
|
||
|
||
# If version is present, check 0.8.0+
|
||
# Simple lexicographical check usually works for semver if format is consistent x.y.z
|
||
# But "0.9.0" > "0.8.0" is true. "0.10.0" > "0.8.0" (lexicographically "0.1" < "0.8") fails.
|
||
# So we need safer parsing.
|
||
parts = version.split(".")
|
||
if len(parts) >= 2:
|
||
major = int(parts[0])
|
||
minor = int(parts[1])
|
||
if major > 0 or (major == 0 and minor >= 8):
|
||
return True
|
||
return False
|
||
except Exception:
|
||
# On error, default to True to assume modern features
|
||
return True
|
||
|
||
# ... (Rest of Action class methods) ...
|