From 01e959536b9bdb0e8daf3df63238125cbdd3ba80 Mon Sep 17 00:00:00 2001 From: wmc1112 <759659013@qq.com> Date: Wed, 22 Oct 2025 16:41:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Flowcharts=20Content=20Renderin?= =?UTF-8?q?g=20Support=20#620?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/streaming/chatStreamMain.tsx | 40 + .../[locale]/chat/streaming/taskWindow.tsx | 41 +- frontend/components/ui/Diagram.tsx | 876 ++++++++++++++++++ frontend/components/ui/markdownRenderer.tsx | 81 +- frontend/package.json | 1 + frontend/postcss.config.mjs | 1 + frontend/public/locales/en/common.json | 12 +- frontend/public/locales/zh/common.json | 12 +- frontend/styles/react-markdown.css | 177 ++++ 9 files changed, 1203 insertions(+), 38 deletions(-) create mode 100644 frontend/components/ui/Diagram.tsx diff --git a/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx b/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx index 6f4d77e90..dde39fed4 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamMain.tsx @@ -398,6 +398,46 @@ export function ChatStreamMain({ shouldScrollToBottom, ]); + // Additional scroll trigger for async content like Mermaid diagrams + useEffect(() => { + if (processedMessages.finalMessages.length > 0 && autoScroll) { + const scrollAreaElement = scrollAreaRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ); + if (!scrollAreaElement) return; + + // Use ResizeObserver to detect when content height changes (e.g., Mermaid diagrams finish rendering) + const resizeObserver = new ResizeObserver(() => { + const { scrollTop, scrollHeight, clientHeight } = + scrollAreaElement as HTMLElement; + const distanceToBottom = scrollHeight - scrollTop - clientHeight; + + // Auto-scroll if user is near bottom and content height changed + if (distanceToBottom < 100) { + scrollToBottom(); + } + }); + + resizeObserver.observe(scrollAreaElement); + + // Also use a timeout as fallback for async content + const timeoutId = setTimeout(() => { + const { scrollTop, scrollHeight, clientHeight } = + scrollAreaElement as HTMLElement; + const distanceToBottom = scrollHeight - scrollTop - clientHeight; + + if (distanceToBottom < 100) { + scrollToBottom(); + } + }, 1000); // Wait 1 second for async content to render + + return () => { + resizeObserver.disconnect(); + clearTimeout(timeoutId); + }; + } + }, [processedMessages.finalMessages.length, autoScroll]); + // Scroll to bottom when task messages are updated useEffect(() => { if (autoScroll) { diff --git a/frontend/app/[locale]/chat/streaming/taskWindow.tsx b/frontend/app/[locale]/chat/streaming/taskWindow.tsx index c8bd3f614..4067c9920 100644 --- a/frontend/app/[locale]/chat/streaming/taskWindow.tsx +++ b/frontend/app/[locale]/chat/streaming/taskWindow.tsx @@ -664,6 +664,7 @@ const messageHandlers: MessageHandler[] = [ ), @@ -757,6 +758,7 @@ const messageHandlers: MessageHandler[] = [ ); } else { @@ -1061,7 +1063,8 @@ export function TaskWindow({ messages, isStreaming = false }: TaskWindowProps) { const maxHeight = 300; const headerHeight = 55; const availableHeight = maxHeight - headerHeight; - const actualContentHeight = Math.min(contentHeight + 16, availableHeight); + // Add extra padding for diagrams to prevent bottom cutoff + const actualContentHeight = Math.min(contentHeight + 32, availableHeight); const containerHeight = isExpanded ? headerHeight + actualContentHeight : "auto"; @@ -1096,15 +1099,15 @@ export function TaskWindow({ messages, isStreaming = false }: TaskWindowProps) { {isExpanded && ( -
+
{needsScroll ? ( -
+
{renderMessages()}
) : ( -
+
{renderMessages()}
)} @@ -1183,6 +1186,36 @@ export function TaskWindow({ messages, isStreaming = false }: TaskWindowProps) { box-sizing: border-box !important; } + /* Override diagram size in task window */ + .task-message-content .my-4 { + max-width: 200px !important; + margin: 0 auto !important; + display: flex !important; + justify-content: center !important; + } + + .task-message-content .my-4 img { + max-width: 200px !important; + width: 200px !important; + margin: 0 auto !important; + display: block !important; + } + + /* More specific selectors for mermaid diagrams */ + .task-message-content .task-message-content .my-4 { + max-width: 200px !important; + margin: 0 auto !important; + display: flex !important; + justify-content: center !important; + } + + .task-message-content .task-message-content .my-4 img { + max-width: 200px !important; + width: 200px !important; + margin: 0 auto !important; + display: block !important; + } + /* Paragraph spacing adjustment */ .task-message-content p { margin-bottom: 0.5rem !important; diff --git a/frontend/components/ui/Diagram.tsx b/frontend/components/ui/Diagram.tsx new file mode 100644 index 000000000..13698e01a --- /dev/null +++ b/frontend/components/ui/Diagram.tsx @@ -0,0 +1,876 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Code, + Download, + Eye, + ZoomIn, + ZoomOut, + FileImage, + FileText, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +// Download format type +type DownloadFormat = "svg" | "png"; + +// Diagram state interface +interface DiagramState { + showCode: boolean; + zoomLevel: number; + panX: number; + panY: number; + downloadFormat: DownloadFormat; +} + +// Global state manager for diagram view states +class DiagramStateManager { + private static instance: DiagramStateManager; + private states: Map = new Map(); + private listeners: Map void>> = new Map(); + + static getInstance(): DiagramStateManager { + if (!DiagramStateManager.instance) { + DiagramStateManager.instance = new DiagramStateManager(); + } + return DiagramStateManager.instance; + } + + getState(diagramId: string): DiagramState { + return ( + this.states.get(diagramId) || { + showCode: false, + zoomLevel: 1, + panX: 0, + panY: 0, + downloadFormat: "svg", + } + ); + } + + setShowCode(diagramId: string, showCode: boolean): void { + const currentState = this.getState(diagramId); + this.states.set(diagramId, { ...currentState, showCode }); + this.notifyListeners(diagramId); + } + + setZoomLevel(diagramId: string, zoomLevel: number): void { + const currentState = this.getState(diagramId); + this.states.set(diagramId, { + ...currentState, + zoomLevel: Math.max(0.1, Math.min(5, zoomLevel)), + }); + this.notifyListeners(diagramId); + } + + setPan(diagramId: string, panX: number, panY: number): void { + const currentState = this.getState(diagramId); + this.states.set(diagramId, { ...currentState, panX, panY }); + this.notifyListeners(diagramId); + } + + setDownloadFormat(diagramId: string, downloadFormat: DownloadFormat): void { + const currentState = this.getState(diagramId); + this.states.set(diagramId, { ...currentState, downloadFormat }); + this.notifyListeners(diagramId); + } + + subscribe(diagramId: string, callback: () => void): () => void { + if (!this.listeners.has(diagramId)) { + this.listeners.set(diagramId, new Set()); + } + this.listeners.get(diagramId)!.add(callback); + + return () => { + this.listeners.get(diagramId)?.delete(callback); + }; + } + + private notifyListeners(diagramId: string): void { + this.listeners.get(diagramId)?.forEach((callback) => callback()); + } +} + +interface DiagramProps { + code: string; + className?: string; + maxHeight?: string | number; + ariaLabel?: string; + showToggle?: boolean; // Controls whether to show toggle buttons +} + +type MermaidApi = { + parse?: (code: string) => Promise | any; + render: ( + id: string, + code: string, + container?: Element + ) => Promise<{ svg: string; bindFunctions?: () => void }>; + initialize: (cfg: Record) => void; +}; + +const memoryCache = new Map(); + +function computeHash(input: string): string { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = (hash * 33) ^ input.charCodeAt(i); + } + return (hash >>> 0).toString(16); +} + +function DiagramComponent({ + code, + className = "", + maxHeight, + ariaLabel, + showToggle = true, +}: DiagramProps) { + const { t } = useTranslation("common"); + const idRef = useRef(); + const resultRef = useRef<{ dataUrl: string } | { error: string } | null>( + null + ); + const cacheKey = useMemo(() => computeHash(code), [code]); + + // Drag state for panning + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + + // Format menu state + const [showFormatMenu, setShowFormatMenu] = useState(false); + + // Close format menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (showFormatMenu && containerRef.current) { + const target = event.target as Node; + const isInsideContainer = containerRef.current.contains(target); + const isFormatMenu = (target as Element)?.closest("[data-format-menu]"); + + if (!isInsideContainer && !isFormatMenu) { + setShowFormatMenu(false); + } + } + }; + + if (showFormatMenu) { + // Use a small delay to avoid immediate closure + const timeoutId = setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 10); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [showFormatMenu]); + + // Dynamic sizing based on diagram type + const [isWideDiagram, setIsWideDiagram] = useState(false); + + // Fixed maxWidth to prevent flicker + const getFixedMaxWidth = () => { + return isWideDiagram ? "300px" : "400px"; + }; + + // Generate stable diagram ID based on code content + const diagramId = useMemo(() => `diagram-${cacheKey}`, [cacheKey]); + + // Use global state manager for persistent state + const stateManager = useMemo(() => DiagramStateManager.getInstance(), []); + const [diagramState, setDiagramState] = useState(() => + stateManager.getState(diagramId) + ); + + // Subscribe to state changes and sync with global state + useEffect(() => { + const unsubscribe = stateManager.subscribe(diagramId, () => { + const newState = stateManager.getState(diagramId); + setDiagramState(newState); + }); + return unsubscribe; + }, [stateManager, diagramId, diagramState]); + + // Update global state when local state changes + const handleToggleShowCode = () => { + const newState = !diagramState.showCode; + stateManager.setShowCode(diagramId, newState); + }; + + const handleZoomIn = () => { + // Limit maximum zoom to prevent excessive scaling + const maxZoom = 3; // Maximum 3x zoom + const newZoom = Math.min(diagramState.zoomLevel * 1.2, maxZoom); + + stateManager.setZoomLevel(diagramId, newZoom); + }; + + const handleZoomOut = () => { + const newZoomLevel = diagramState.zoomLevel / 1.2; + + stateManager.setZoomLevel(diagramId, newZoomLevel); + + // Reset pan position when zoom level goes back to 1 or below + if (newZoomLevel <= 1) { + stateManager.setPan(diagramId, 0, 0); + } + }; + + // Drag handling functions + const handleMouseDown = (e: React.MouseEvent) => { + if (diagramState.zoomLevel > 1) { + setIsDragging(true); + setDragStart({ + x: e.clientX - diagramState.panX, + y: e.clientY - diagramState.panY, + }); + e.preventDefault(); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (isDragging && diagramState.zoomLevel > 1) { + const newPanX = e.clientX - dragStart.x; + const newPanY = e.clientY - dragStart.y; + stateManager.setPan(diagramId, newPanX, newPanY); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleMouseLeave = () => { + setIsDragging(false); + }; + + // Keyboard navigation support + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setIsDragging(false); + } + }; + + // Convert SVG to PNG + const convertSvgToPng = async (svgContent: string): Promise => { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + + img.onload = () => { + // Set canvas size to match SVG dimensions + canvas.width = img.width; + canvas.height = img.height; + + // Fill with white background + if (ctx) { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw the SVG + ctx.drawImage(img, 0, 0); + + // Convert to PNG data URL + const pngDataUrl = canvas.toDataURL("image/png"); + resolve(pngDataUrl); + } else { + reject(new Error("Failed to get canvas context")); + } + }; + + img.onerror = () => { + reject(new Error("Failed to load SVG")); + }; + + // Use the SVG content directly as data URL, not base64 encoded + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + svgContent + )}`; + }); + }; + + // Download function + const handleDownloadClick = (e: React.MouseEvent) => { + // Prevent event bubbling to avoid triggering handleClickOutside + e.stopPropagation(); + e.preventDefault(); + + setShowFormatMenu(!showFormatMenu); + }; + + const handleFormatSelect = async (format: DownloadFormat) => { + setShowFormatMenu(false); + + // Use resultRef.current instead of result state for more reliable access + const currentResult = resultRef.current; + + // Try currentResult first, then fallback to result state + const dataSource = currentResult || result; + + if (dataSource && "dataUrl" in dataSource) { + try { + // Extract SVG content from data URL + let svgContent: string; + if ( + dataSource.dataUrl.startsWith("data:image/svg+xml;charset=utf-8,") + ) { + // Already encoded SVG content + svgContent = decodeURIComponent(dataSource.dataUrl.split(",")[1]); + } else if ( + dataSource.dataUrl.startsWith("data:image/svg+xml;base64,") + ) { + // Base64 encoded SVG content + const base64Content = dataSource.dataUrl.split(",")[1]; + svgContent = atob(base64Content); + } else { + // Fallback: try to decode as URI component + svgContent = decodeURIComponent(dataSource.dataUrl.split(",")[1]); + } + + let blob: Blob; + let filename: string; + let mimeType: string; + + if (format === "png") { + // Convert SVG to PNG + const pngDataUrl = await convertSvgToPng(svgContent); + const pngData = pngDataUrl.split(",")[1]; + blob = new Blob( + [Uint8Array.from(atob(pngData), (c) => c.charCodeAt(0))], + { type: "image/png" } + ); + filename = `diagram-${cacheKey}.png`; + mimeType = "image/png"; + } else { + // Use SVG directly + blob = new Blob([svgContent], { type: "image/svg+xml" }); + filename = `diagram-${cacheKey}.svg`; + mimeType = "image/svg+xml"; + } + // Create download link + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up + URL.revokeObjectURL(url); + } catch (error) { + // Fallback to SVG download + try { + let svgContent: string; + if ( + dataSource.dataUrl.startsWith("data:image/svg+xml;charset=utf-8,") + ) { + svgContent = decodeURIComponent(dataSource.dataUrl.split(",")[1]); + } else if ( + dataSource.dataUrl.startsWith("data:image/svg+xml;base64,") + ) { + const base64Content = dataSource.dataUrl.split(",")[1]; + svgContent = atob(base64Content); + } else { + svgContent = decodeURIComponent(dataSource.dataUrl.split(",")[1]); + } + + const blob = new Blob([svgContent], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `diagram-${cacheKey}.svg`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (fallbackError) { + // Silent fallback failure + } + } + } + }; + + // Generate stable ID only once + if (!idRef.current) { + idRef.current = `mmd-${Math.random().toString(36).slice(2)}`; + } + + // Initialize result from cache if available + if (!resultRef.current) { + const cached = memoryCache.get(cacheKey); + if (cached) { + resultRef.current = { dataUrl: cached }; + } + } + + const [result, setResult] = useState< + { dataUrl: string } | { error: string } | null + >(resultRef.current); + + useEffect(() => { + let cancelled = false; + + // If we already have a result, don't re-render + if (resultRef.current) { + return; + } + + const run = async () => { + try { + const mod = await import("mermaid"); + const mermaid: MermaidApi = mod.default as unknown as MermaidApi; + mermaid.initialize({ + startOnLoad: false, + securityLevel: "loose", + theme: "base", + fontFamily: "inherit", + // Optimize Gantt chart rendering + themeVariables: { + // Primary color - using project blue + primaryColor: "#3b82f6", + lineColor: "#6b7280", + + // Background colors - light theme + background: "#ffffff", + mainBkg: "#ffffff", + secondBkg: "#f8fafc", + tertiaryBkg: "#f1f5f9", + + // Text colors - gray theme + textColor: "#6b7280", + titleColor: "#6b7280", + labelTextColor: "#6b7280", + // Force set all possible text colors + primaryTextColor: "#6b7280", + sectionBkgColor: "#f8fafc", + altSectionBkgColor: "#f1f5f9", + secondaryColor: "#9ca3af", + tertiaryColor: "#d1d5db", + + // Node colors + nodeBkg: "#ffffff", + nodeBorder: "#d1d5db", + clusterBkg: "#f9fafb", + clusterBorder: "#e5e7eb", + + // Arrows and connection lines + arrowheadColor: "#6b7280", + edgeLabelBackground: "#f8fafc", + + // Font sizes + titleFontSize: "14px", + + // Force text color settings + + // Gantt chart colors + section0: "#f0f9ff", + section1: "#fef3c7", + section2: "#fce7f3", + section3: "#ecfdf5", + section4: "#fef2f2", + + // Task colors + task0: "#3b82f6", + task1: "#f59e0b", + task2: "#ec4899", + task3: "#10b981", + task4: "#ef4444", + taskTextLightColor: "#ffffff", + taskTextColor: "#6b7280", + taskTextOutsideColor: "#6b7280", + taskTextClickableColor: "#4b5563", + + // Active task colors + activeTaskBkgColor: "#dbeafe", + activeTaskBorderColor: "#3b82f6", + gridLineColor: "#e5e7eb", + + // Timeline + todayLineColor: "#ef4444", + }, + flowchart: { + useMaxWidth: true, + htmlLabels: false, + nodeSpacing: 25, + rankSpacing: 30, + diagramPadding: 8, + curve: "basis", + }, + sequence: { + boxMargin: 8, + diagramMarginX: 8, + diagramMarginY: 8, + actorFontSize: 12, + noteFontSize: 10, + messageFontSize: 11, + messageAlign: "center", + actorFontFamily: "inherit", + messageFontFamily: "inherit", + noteFontFamily: "inherit", + actorFontWeight: "500", + messageFontWeight: "400", + noteFontWeight: "400", + }, + gantt: { + useMaxWidth: true, + htmlLabels: false, + fontSize: 14, + topPadding: 30, + leftPadding: 30, + gridLineStartPadding: 20, + sectionFontSize: 14, + sectionFontWeight: "600", + sectionFontFamily: "inherit", + taskFontSize: 12, + taskFontWeight: "500", + taskFontFamily: "inherit", + labelFontSize: 12, + labelFontWeight: "500", + labelFontFamily: "inherit", + gridLineColor: "#e5e7eb", + // Increase timeline label spacing + axisFormat: "%m-%d", + bottomPadding: 40, + rightPadding: 20, + // Optimize timeline display + axisTextColor: "#6b7280", + axisTextFontSize: 11, + axisTextFontWeight: "500", + }, + pie: { + textPosition: 0.75, + titleFontSize: 16, + titleFontWeight: "600", + titleFontFamily: "inherit", + textFontSize: 12, + textFontWeight: "400", + textFontFamily: "inherit", + }, + quadrantChart: { + chartWidth: 400, + chartHeight: 400, + titleFontSize: 16, + titleFontWeight: "600", + titleFontFamily: "inherit", + quadrant1TextFill: "#6b7280", + quadrant2TextFill: "#6b7280", + quadrant3TextFill: "#6b7280", + quadrant4TextFill: "#6b7280", + quadrant1Fill: "#f0f9ff", + quadrant2Fill: "#fef3c7", + quadrant3Fill: "#fce7f3", + quadrant4Fill: "#ecfdf5", + quadrantXAxisTextFill: "#9ca3af", + quadrantYAxisTextFill: "#9ca3af", + quadrantTitleFill: "#6b7280", + quadrantInternalBorderStrokeFill: "#d1d5db", + quadrantExternalBorderStrokeFill: "#9ca3af", + }, + xyChart: { + width: 400, + height: 300, + titleFontSize: 16, + titleFontWeight: "600", + titleFontFamily: "inherit", + xAxisLabelFontSize: 12, + xAxisLabelFontWeight: "400", + xAxisLabelFontFamily: "inherit", + yAxisLabelFontSize: 12, + yAxisLabelFontWeight: "400", + yAxisLabelFontFamily: "inherit", + xAxisTitleFontSize: 14, + xAxisTitleFontWeight: "500", + xAxisTitleFontFamily: "inherit", + yAxisTitleFontSize: 14, + yAxisTitleFontWeight: "500", + yAxisTitleFontFamily: "inherit", + chartOrientation: "vertical", + chartWidth: 400, + chartHeight: 300, + showValues: true, + showValuesFontSize: 10, + showValuesFontWeight: "400", + showValuesFontFamily: "inherit", + }, + }); + + if (typeof mermaid.parse === "function") { + await mermaid.parse(code); + } + + // Offscreen container for stable layout measurement + const container = document.createElement("div"); + container.style.position = "absolute"; + container.style.visibility = "hidden"; + container.style.left = "-9999px"; + container.style.top = "0"; + document.body.appendChild(container); + + try { + const { svg } = await mermaid.render(idRef.current!, code, container); + + // Process SVG for rendering + + // Sanitize minimal: strip script and on* attributes + const sanitized = svg + .replace(//gi, "") + .replace(/ on[a-z]+="[^"]*"/gi, "") + .replace(/ on[a-z]+='[^']*'/gi, ""); + + // Ensure preserveAspectRatio and vector-effect, but keep original dimensions + const withSvgAttrs = sanitized.replace(//i, (_m, attrs) => { + // Extract viewBox dimensions and set explicit width/height + const viewBoxMatch = attrs.match(/viewBox="([^"]*)"/i); + let processedAttrs = String(attrs); + + if (viewBoxMatch) { + const viewBoxParts = viewBoxMatch[1].split(/\s+/); + if (viewBoxParts.length >= 4) { + const width = viewBoxParts[2]; + const height = viewBoxParts[3]; + + // Replace percentage width with actual pixel width + processedAttrs = processedAttrs.replace( + /width="[^"]*"/i, + `width="${width}"` + ); + + // Add height if missing + if (!processedAttrs.match(/height="[^"]*"/i)) { + processedAttrs = processedAttrs.replace( + /`; + }); + + const withVectorEffect = withSvgAttrs.replace( + / { + cancelled = true; + }; + }, [cacheKey, code]); + + + if (result && "error" in result) { + return ( +
+
+
+            {code}
+          
+
+
+ ); + } + + return ( +
+ {/* Control buttons - only show if showToggle is true */} + {showToggle && ( +
+ {!diagramState.showCode && ( + <> + + +
+ + {showFormatMenu && ( +
{ + e.stopPropagation(); + }} + > +
+ + +
+
+ )} +
+ + )} + +
+ )} + + {/* Content area */} + {diagramState.showCode ? ( +
+
+            {code}
+          
+
+ ) : ( + <> + {!result || !("dataUrl" in result) ? ( +
+
+
+ ) : ( +
1 + ? isDragging + ? "grabbing" + : "grab" + : "default", + }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseLeave} + onKeyDown={handleKeyDown} + tabIndex={diagramState.zoomLevel > 1 ? 0 : -1} + > + {ariaLabel { + const img = e.target as HTMLImageElement; + const aspectRatio = img.naturalWidth / img.naturalHeight; + const isWide = aspectRatio > 1.5; // Aspect ratio > 1.5 is considered a wide chart + + setIsWideDiagram(isWide); + }} + /> +
+ )} + + )} +
+ ); +} + +// Memoize the component to prevent unnecessary re-renders +export const Diagram = React.memo(DiagramComponent); diff --git a/frontend/components/ui/markdownRenderer.tsx b/frontend/components/ui/markdownRenderer.tsx index cda50fba5..62f621b4b 100644 --- a/frontend/components/ui/markdownRenderer.tsx +++ b/frontend/components/ui/markdownRenderer.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; @@ -19,11 +21,13 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { CopyButton } from "@/components/ui/copyButton"; +import { Diagram } from "@/components/ui/Diagram"; interface MarkdownRendererProps { content: string; className?: string; searchResults?: SearchResult[]; + showDiagramToggle?: boolean; } // Get background color for different tool signs @@ -350,6 +354,7 @@ export const MarkdownRenderer: React.FC = ({ content, className, searchResults = [], + showDiagramToggle = true, }) => { const { t } = useTranslation("common"); @@ -397,7 +402,7 @@ export const MarkdownRenderer: React.FC = ({ const processText = (text: string) => { if (typeof text !== "string") return text; - const parts = text.split(/(\[\[[^\]]+\]\])/g); + const parts = text.split(/(\[\[[^\]]+\]\]|:mermaid\[[^\]]+\])/g); return ( <> {parts.map((part, index) => { @@ -426,6 +431,12 @@ export const MarkdownRenderer: React.FC = ({ return ""; } } + // Inline Mermaid using :mermaid[graph LR; A-->B] - removed inline support + const mmd = part.match(/^:mermaid\[([^\]]+)\]$/); + if (mmd) { + const code = mmd[1]; + return ; + } return part; })} @@ -595,38 +606,44 @@ export const MarkdownRenderer: React.FC = ({ ? children.join("") : children ?? ""; const codeContent = String(raw).replace(/^\n+|\n+$/g, ""); - if (!inline && match && match[1]) { - return ( -
-
- - {match[1]} - - -
-
- - {codeContent} - + if (match && match[1]) { + // Check if it's a Mermaid diagram + if (match[1] === "mermaid") { + return ; + } + if (!inline) { + return ( +
+
+ + {match[1]} + + +
+
+ + {codeContent} + +
-
- ); + ); + } } } catch (error) { // Handle error silently diff --git a/frontend/package.json b/frontend/package.json index 827531e16..d818cf459 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,6 +60,7 @@ "input-otp": "1.4.1", "katex": "^0.16.11", "lucide-react": "^0.454.0", + "mermaid": "^11.12.0", "next": "15.4.5", "next-i18next": "^15.4.2", "next-themes": "^0.4.4", diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs index 1a69fd2a4..2ef30fcf4 100644 --- a/frontend/postcss.config.mjs +++ b/frontend/postcss.config.mjs @@ -2,6 +2,7 @@ const config = { plugins: { tailwindcss: {}, + autoprefixer: {}, }, }; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 327f0580e..38b37256a 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -938,5 +938,15 @@ "businessLogic.config.error.loadModelsFailed": "Failed to load available models", "businessLogic.config.error.noAvailableModels": "No available models", "businessLogic.config.error.modelUpdateFailed": "Failed to update agent model", - "businessLogic.config.error.maxStepsUpdateFailed": "Failed to update agent max steps" + "businessLogic.config.error.maxStepsUpdateFailed": "Failed to update agent max steps", + + "diagram.button.showDiagram": "Show Diagram", + "diagram.button.showCode": "Show Code", + "diagram.button.zoomOut": "Zoom Out", + "diagram.button.zoomIn": "Zoom In", + "diagram.button.download": "Download", + "diagram.format.svg": "SVG", + "diagram.format.png": "PNG", + "diagram.format.selectFormat": "Select Format", + "diagram.error.renderFailed": "Render Failed" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9696afcbe..104ad1089 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -938,5 +938,15 @@ "businessLogic.config.error.loadModelsFailed": "加载可用模型失败", "businessLogic.config.error.noAvailableModels": "暂无可用的模型", "businessLogic.config.error.modelUpdateFailed": "更新Agent模型失败", - "businessLogic.config.error.maxStepsUpdateFailed": "更新Agent最大步数失败" + "businessLogic.config.error.maxStepsUpdateFailed": "更新Agent最大步数失败", + + "diagram.button.showDiagram": "显示图表", + "diagram.button.showCode": "显示代码", + "diagram.button.zoomOut": "缩小", + "diagram.button.zoomIn": "放大", + "diagram.button.download": "下载", + "diagram.format.svg": "SVG", + "diagram.format.png": "PNG", + "diagram.format.selectFormat": "选择格式", + "diagram.error.renderFailed": "渲染失败" } diff --git a/frontend/styles/react-markdown.css b/frontend/styles/react-markdown.css index 9b61f6d52..aa3ef310d 100644 --- a/frontend/styles/react-markdown.css +++ b/frontend/styles/react-markdown.css @@ -428,3 +428,180 @@ .code-block-content pre::-webkit-scrollbar-thumb:hover { background: #aaa; } + +/* Mermaid Diagram Styles */ +.mermaid-container { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + overflow: hidden; + background-color: #ffffff; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + margin: 1rem 0; + transition: box-shadow 0.2s ease; +} + +.mermaid-container:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.mermaid-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: linear-gradient(to right, #f9fafb, #f3f4f6); + border-bottom: 1px solid #e5e7eb; +} + +.mermaid-label { + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.mermaid-copy-button { + transition: background-color 0.2s; + border-radius: 0.375rem; + padding: 0.25rem; +} + +.mermaid-copy-button:hover { + background-color: #e5e7eb; +} + +.mermaid-content { + position: relative; +} + +.mermaid-diagram { + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + min-height: 120px; + overflow: visible; /* allow container to grow with content */ + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); +} + +.mermaid-diagram svg { + width: 100%; + max-width: 100%; + height: auto; + display: block; /* ensure responsive SVG sizing */ + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.05)); +} +.mermaid-inline { + display: inline-block; + vertical-align: middle; + line-height: 1; +} + +.mermaid-inline-svg svg { + height: 1.25em; + width: auto; + display: inline-block; + vertical-align: middle; +} + +/* Generic Diagram helpers for new Diagram component */ +.diagram-block { + display: block; + width: 100%; +} + +.diagram-inline { + display: inline-block; + vertical-align: baseline; + line-height: 1; +} + +.mermaid-code-display { + padding: 1rem; + background-color: #f9fafb; + font-size: 0.875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + overflow-x: auto; + white-space: pre-wrap; +} + +.mermaid-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: #6b7280; +} + +.mermaid-error-container { + border: 1px solid #fecaca; + border-radius: 0.5rem; + overflow: hidden; + background-color: #fef2f2; + margin: 1rem 0; +} + +.mermaid-error-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background-color: #fee2e2; + border-bottom: 1px solid #fecaca; +} + +.mermaid-error-label { + font-size: 0.875rem; + font-weight: 500; + color: #b91c1c; +} + +.mermaid-error-content { + padding: 1rem; +} + +.mermaid-error-message { + color: #dc2626; +} + +/* Gantt chart optimization styles */ +.mermaid svg { + /* Ensure Gantt chart has enough space */ + min-width: 100%; + overflow: visible; +} + +/* Gantt chart timeline label optimization */ +.mermaid svg .axis text { + font-size: 11px !important; + font-weight: 500 !important; + fill: #6b7280 !important; + text-anchor: middle !important; + dominant-baseline: hanging !important; +} + +/* Gantt chart grid line optimization */ +.mermaid svg .grid .tick line { + stroke: #e5e7eb !important; + stroke-width: 1px !important; +} + +/* Gantt chart task bar optimization */ +.mermaid svg .task text { + font-size: 12px !important; + font-weight: 500 !important; + fill: #374151 !important; +} + +/* Gantt chart section title optimization */ +.mermaid svg .section text { + font-size: 14px !important; + font-weight: 600 !important; + fill: #374151 !important; +} + +/* Ensure Gantt chart container has sufficient padding */ +.mermaid { + padding: 20px !important; + margin: 10px 0 !important; +} \ No newline at end of file From 8c9c4ab083cc6ee281c7599ef60cc2adbe7e8b4e Mon Sep 17 00:00:00 2001 From: wmc1112 <759659013@qq.com> Date: Thu, 23 Oct 2025 16:33:01 +0800 Subject: [PATCH 2/2] Fix unit test cause by version of opentelemetry --- sdk/pyproject.toml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index cf28471f2..44c239998 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -69,12 +69,13 @@ data_process = [ ] performance = [ # OpenTelemetry Core Components - "opentelemetry-api", - "opentelemetry-sdk", + "opentelemetry-api==1.20.0", + "opentelemetry-sdk==1.20.0", + "opentelemetry-semantic-conventions==0.41b0", # OpenTelemetry Instrumentation - "opentelemetry-instrumentation", - "opentelemetry-instrumentation-fastapi", - "opentelemetry-instrumentation-requests", + "opentelemetry-instrumentation==0.41b0", + "opentelemetry-instrumentation-fastapi==0.41b0", + "opentelemetry-instrumentation-requests==0.41b0", # OpenTelemetry Exporters "opentelemetry-exporter-jaeger", "opentelemetry-exporter-prometheus",