diff --git a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx new file mode 100644 index 0000000..8ba0f3d --- /dev/null +++ b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx @@ -0,0 +1,428 @@ +import React, { useState } from 'react'; +import { Tooltip, message } from 'antd'; +import { CheckOutlined, CopyOutlined, PlayCircleOutlined } from '@ant-design/icons'; +import mermaid from 'mermaid'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; +import { buildAIReadonlyPreviewSQL } from '../../../utils/aiSqlLimit'; + +interface AIMessageCodeBlockProps { + className?: string; + inline?: boolean; + children?: React.ReactNode; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + activeConnectionConfig?: any; + activeConnectionId?: string; + activeDbName?: string; +} + +interface HighlightedCodeBlockProps { + language: string; + codeText: string; + displayText: string; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + activeConnectionConfig?: any; + activeConnectionId?: string; + activeDbName?: string; +} + +const MermaidRenderer: React.FC<{ chart: string; darkMode: boolean }> = ({ chart, darkMode }) => { + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (!containerRef.current) { + return; + } + try { + mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' }); + const id = `mermaid-${Math.random().toString(36).slice(2)}`; + (async () => { + const result: any = await mermaid.render(id, chart); + if (containerRef.current) { + containerRef.current.innerHTML = result.svg || result; + } + })().catch((error: any) => { + if (containerRef.current) { + containerRef.current.innerHTML = `
Mermaid 解析失败: ${error.message}
`; + } + }); + } catch (error: any) { + if (containerRef.current) { + containerRef.current.innerHTML = `
Mermaid 渲染异常: ${error.message}
`; + } + } + }, [chart, darkMode]); + + return
; +}; + +const CodeCopyButton: React.FC<{ text: string }> = ({ text }) => { + const [copied, setCopied] = useState(false); + + return ( + { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + opacity: copied ? 1 : 0.6, + transition: 'opacity 0.2s', + }} + onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }} + onMouseLeave={(event) => { event.currentTarget.style.opacity = copied ? '1' : '0.6'; }} + > + {copied ? : } + {copied ? '已复制' : '复制代码'} + + ); +}; + +const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: string }> = ({ text, connectionId, dbName }) => { + const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m); + const resolvedConnId = contextMatch?.[1] || connectionId; + const resolvedDbName = contextMatch?.[2] || dbName; + const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); + const sqlDetail = (runImmediately: boolean) => ({ + sql: cleanSql, + runImmediately, + connectionId: resolvedConnId, + dbName: resolvedDbName, + }); + + const handleExecute = async () => { + try { + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AICheckSQL) { + const result = await Service.AICheckSQL(text); + if (!result.allowed) { + message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`); + return; + } + if (result.requiresConfirm) { + const { Modal } = await import('antd'); + Modal.confirm({ + title: '⚠️ 安全确认', + content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, + okText: '确认执行', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: () => { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + }, + }); + return; + } + } + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + } catch { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + } + }; + + return ( +
+ + { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) })); + }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + opacity: 0.6, + transition: 'opacity 0.2s', + padding: '0 4px', + color: '#10b981', + }} + onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }} + onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} + > + + 插入 + + + + { event.currentTarget.style.opacity = '1'; }} + onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} + > + + 执行 + + +
+ ); +}; + +const HighlightedCodeBlock: React.FC = ({ + language, + codeText, + displayText, + darkMode, + overlayTheme, + activeConnectionConfig, + activeConnectionId, + activeDbName, +}) => { + const [expanded, setExpanded] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [previewCols, setPreviewCols] = useState([]); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(''); + const [previewExpanded, setPreviewExpanded] = useState(false); + const isLongCode = displayText.split('\n').length > 15; + const isSql = language === 'sql'; + const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim()); + + const handleInlineExecute = async () => { + if (!activeConnectionConfig || previewLoading) { + return; + } + setPreviewLoading(true); + setPreviewError(''); + setPreviewData(null); + try { + const { DBQuery } = await import('../../../../wailsjs/go/app/App'); + const previewSql = buildAIReadonlyPreviewSQL( + activeConnectionConfig?.type || '', + displayText, + 50, + activeConnectionConfig?.driver || '', + ); + const response = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql); + if (response.success && Array.isArray(response.data)) { + const rows = response.data as any[]; + setPreviewCols(rows.length > 0 ? Object.keys(rows[0]) : []); + setPreviewData(rows.slice(0, 20)); + setPreviewExpanded(true); + } else { + setPreviewError(response.message || '查询无结果'); + } + } catch (error: any) { + setPreviewError(error?.message || '执行失败'); + } finally { + setPreviewLoading(false); + } + }; + + return ( +
+
+ {language} +
+ {isSql && } + {isSelectQuery && activeConnectionConfig && ( + + { + if (!previewLoading) { + event.currentTarget.style.opacity = '1'; + } + }} + onMouseLeave={(event) => { + if (!previewLoading) { + event.currentTarget.style.opacity = '0.6'; + } + }} + > + {previewLoading ? '⏳' : '👁'} + {previewLoading ? '执行中...' : '预览'} + + + )} + +
+
+ +
+ + {displayText} + + + {!expanded && isLongCode && ( +
setExpanded(true)} + > + + 展开全部代码 + +
+ )} + {expanded && isLongCode && ( +
setExpanded(false)} + > + 收起代码 +
+ )} +
+ + {previewError && ( +
+ ❌ {previewError} +
+ )} + {previewExpanded && previewData && previewData.length > 0 && ( +
+
+ 📊 预览结果({previewData.length} 行 × {previewCols.length} 列) + setPreviewExpanded(false)}>收起 ▴ +
+
+ + + + {previewCols.map((column) => ( + + ))} + + + + {previewData.map((row, rowIndex) => ( + + {previewCols.map((column) => ( + + ))} + + ))} + +
+ {column} +
+ {row[column] === null ? NULL : String(row[column])} +
+
+
+ )} + {!previewExpanded && previewData && previewData.length > 0 && ( +
setPreviewExpanded(true)} + > + 📊 查看结果({previewData.length} 行)▾ +
+ )} +
+ ); +}; + +export const AIMessageCodeBlock: React.FC = ({ + className, + inline, + children, + darkMode, + overlayTheme, + activeConnectionConfig, + activeConnectionId, + activeDbName, +}) => { + const match = /language-(\w+)/.exec(className || ''); + if (!inline && match && match[1] === 'mermaid') { + return ; + } + + if (!inline && match) { + const codeText = String(children).replace(/\n$/, ''); + const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); + + return ( + + ); + } + + return {children}; +}; diff --git a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx index 2e48aa9..ab3f8de 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx @@ -1,10 +1,22 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; import { AIMessageMarkdown } from './AIMessageMarkdown'; import { buildOverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; +vi.mock('antd', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + message: { error: vi.fn() }, +})); + +vi.mock('@ant-design/icons', () => ({ + CheckOutlined: () => null, + CopyOutlined: () => null, + PlayCircleOutlined: () => null, +})); + describe('AIMessageMarkdown', () => { it('keeps SQL code block actions after extracting markdown rendering', () => { const markup = renderToStaticMarkup( @@ -23,4 +35,45 @@ describe('AIMessageMarkdown', () => { expect(markup).toContain('执行'); expect(markup).toContain('预览'); }); + + it('can switch between fenced code renderers without changing hook order', () => { + const overlayTheme = buildOverlayWorkbenchTheme(false); + let renderer: ReactTestRenderer | undefined; + + try { + expect(() => { + act(() => { + renderer = create( + , + ); + }); + act(() => { + renderer?.update( + B;\n```'} + darkMode={false} + overlayTheme={overlayTheme} + />, + ); + }); + act(() => { + renderer?.update( + , + ); + }); + }).not.toThrow(); + } finally { + act(() => { + renderer?.unmount(); + }); + } + }); }); diff --git a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx index 28f9cb8..52795a7 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx @@ -1,15 +1,10 @@ -import React, { useState } from 'react'; -import { Tooltip, message } from 'antd'; -import { CheckOutlined, CopyOutlined, PlayCircleOutlined } from '@ant-design/icons'; +import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import mermaid from 'mermaid'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; import { normalizeAiMarkdown } from '../../../utils/aiMarkdown'; -import { buildAIReadonlyPreviewSQL } from '../../../utils/aiSqlLimit'; +import { AIMessageCodeBlock } from './AIMessageCodeBlock'; const remarkPlugins = [remarkGfm]; @@ -22,389 +17,6 @@ interface AIMessageMarkdownProps { activeDbName?: string; } -interface AICodeBlockProps { - className?: string; - inline?: boolean; - children?: React.ReactNode; - darkMode: boolean; - overlayTheme: OverlayWorkbenchTheme; - activeConnectionConfig?: any; - activeConnectionId?: string; - activeDbName?: string; -} - -const MermaidRenderer: React.FC<{ chart: string; darkMode: boolean }> = ({ chart, darkMode }) => { - const containerRef = React.useRef(null); - - React.useEffect(() => { - if (!containerRef.current) { - return; - } - try { - mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' }); - const id = `mermaid-${Math.random().toString(36).slice(2)}`; - (async () => { - const result: any = await mermaid.render(id, chart); - if (containerRef.current) { - containerRef.current.innerHTML = result.svg || result; - } - })().catch((error: any) => { - if (containerRef.current) { - containerRef.current.innerHTML = `
Mermaid 解析失败: ${error.message}
`; - } - }); - } catch (error: any) { - if (containerRef.current) { - containerRef.current.innerHTML = `
Mermaid 渲染异常: ${error.message}
`; - } - } - }, [chart, darkMode]); - - return
; -}; - -const CodeCopyButton: React.FC<{ text: string }> = ({ text }) => { - const [copied, setCopied] = useState(false); - - return ( - { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }} - style={{ - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - opacity: copied ? 1 : 0.6, - transition: 'opacity 0.2s', - }} - onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }} - onMouseLeave={(event) => { event.currentTarget.style.opacity = copied ? '1' : '0.6'; }} - > - {copied ? : } - {copied ? '已复制' : '复制代码'} - - ); -}; - -const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: string }> = ({ text, connectionId, dbName }) => { - const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m); - const resolvedConnId = contextMatch?.[1] || connectionId; - const resolvedDbName = contextMatch?.[2] || dbName; - const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); - const sqlDetail = (runImmediately: boolean) => ({ - sql: cleanSql, - runImmediately, - connectionId: resolvedConnId, - dbName: resolvedDbName, - }); - - const handleExecute = async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AICheckSQL) { - const result = await Service.AICheckSQL(text); - if (!result.allowed) { - message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`); - return; - } - if (result.requiresConfirm) { - const { Modal } = await import('antd'); - Modal.confirm({ - title: '⚠️ 安全确认', - content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, - okText: '确认执行', - cancelText: '取消', - okButtonProps: { danger: true }, - onOk: () => { - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); - }, - }); - return; - } - } - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); - } catch { - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); - } - }; - - return ( -
- - { - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) })); - }} - style={{ - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - opacity: 0.6, - transition: 'opacity 0.2s', - padding: '0 4px', - color: '#10b981', - }} - onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }} - onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} - > - - 插入 - - - - { event.currentTarget.style.opacity = '1'; }} - onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} - > - - 执行 - - -
- ); -}; - -const AICodeBlock: React.FC = ({ - className, - inline, - children, - darkMode, - overlayTheme, - activeConnectionConfig, - activeConnectionId, - activeDbName, -}) => { - const match = /language-(\w+)/.exec(className || ''); - if (!inline && match && match[1] === 'mermaid') { - return ; - } - - if (!inline && match) { - const codeText = String(children).replace(/\n$/, ''); - const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); - const [expanded, setExpanded] = useState(false); - const [previewData, setPreviewData] = useState(null); - const [previewCols, setPreviewCols] = useState([]); - const [previewLoading, setPreviewLoading] = useState(false); - const [previewError, setPreviewError] = useState(''); - const [previewExpanded, setPreviewExpanded] = useState(false); - const isLongCode = displayText.split('\n').length > 15; - const isSql = match[1] === 'sql'; - const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim()); - - const handleInlineExecute = async () => { - if (!activeConnectionConfig || previewLoading) { - return; - } - setPreviewLoading(true); - setPreviewError(''); - setPreviewData(null); - try { - const { DBQuery } = await import('../../../../wailsjs/go/app/App'); - const previewSql = buildAIReadonlyPreviewSQL( - activeConnectionConfig?.type || '', - displayText, - 50, - activeConnectionConfig?.driver || '', - ); - const response = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql); - if (response.success && Array.isArray(response.data)) { - const rows = response.data as any[]; - setPreviewCols(rows.length > 0 ? Object.keys(rows[0]) : []); - setPreviewData(rows.slice(0, 20)); - setPreviewExpanded(true); - } else { - setPreviewError(response.message || '查询无结果'); - } - } catch (error: any) { - setPreviewError(error?.message || '执行失败'); - } finally { - setPreviewLoading(false); - } - }; - - return ( -
-
- {match[1]} -
- {isSql && } - {isSelectQuery && activeConnectionConfig && ( - - { - if (!previewLoading) { - event.currentTarget.style.opacity = '1'; - } - }} - onMouseLeave={(event) => { - if (!previewLoading) { - event.currentTarget.style.opacity = '0.6'; - } - }} - > - {previewLoading ? '⏳' : '👁'} - {previewLoading ? '执行中...' : '预览'} - - - )} - -
-
- -
- - {displayText} - - - {!expanded && isLongCode && ( -
setExpanded(true)} - > - - 展开全部代码 - -
- )} - {expanded && isLongCode && ( -
setExpanded(false)} - > - 收起代码 -
- )} -
- - {previewError && ( -
- ❌ {previewError} -
- )} - {previewExpanded && previewData && previewData.length > 0 && ( -
-
- 📊 预览结果({previewData.length} 行 × {previewCols.length} 列) - setPreviewExpanded(false)}>收起 ▴ -
-
- - - - {previewCols.map((column) => ( - - ))} - - - - {previewData.map((row, rowIndex) => ( - - {previewCols.map((column) => ( - - ))} - - ))} - -
- {column} -
- {row[column] === null ? NULL : String(row[column])} -
-
-
- )} - {!previewExpanded && previewData && previewData.length > 0 && ( -
setPreviewExpanded(true)} - > - 📊 查看结果({previewData.length} 行)▾ -
- )} -
- ); - } - - return {children}; -}; - export const AIMessageMarkdown: React.FC = React.memo(({ content, darkMode, @@ -417,7 +29,7 @@ export const AIMessageMarkdown: React.FC = React.memo(({ const components = React.useMemo(() => ({ code({ inline, className, children }: any) { return ( - = React.memo(({ activeDbName={activeDbName} > {children} - + ); }, }), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);