mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 17:31:32 +08:00
♻️ refactor(ai-message): 拆分 Markdown 代码块渲染逻辑
This commit is contained in:
428
frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
Normal file
428
frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
Normal file
@@ -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<HTMLDivElement>(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 = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${error.message}</div>`;
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
}, [chart, darkMode]);
|
||||
|
||||
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
|
||||
};
|
||||
|
||||
const CodeCopyButton: React.FC<{ text: string }> = ({ text }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="ai-code-copy-btn"
|
||||
onClick={() => {
|
||||
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 ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
|
||||
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={() => {
|
||||
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'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>插入</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="立即执行(受 AI 安全策略管控)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={handleExecute}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.2s',
|
||||
padding: '0 4px',
|
||||
color: '#1677ff',
|
||||
}}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>执行</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HighlightedCodeBlock: React.FC<HighlightedCodeBlockProps> = ({
|
||||
language,
|
||||
codeText,
|
||||
displayText,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
activeConnectionConfig,
|
||||
activeConnectionId,
|
||||
activeDbName,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<any[] | null>(null);
|
||||
const [previewCols, setPreviewCols] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div
|
||||
className="ai-code-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '6px 12px',
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
fontSize: 12,
|
||||
color: overlayTheme.mutedText,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--gn-font-mono)' }}>{language}</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
{isSql && <CodeRunButton text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
|
||||
{isSelectQuery && activeConnectionConfig && (
|
||||
<Tooltip title="在聊天内预览查询结果(最多20行)">
|
||||
<span
|
||||
onClick={handleInlineExecute}
|
||||
style={{
|
||||
cursor: previewLoading ? 'wait' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: previewLoading ? 1 : 0.6,
|
||||
transition: 'opacity 0.2s',
|
||||
padding: '0 4px',
|
||||
color: '#faad14',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
if (!previewLoading) {
|
||||
event.currentTarget.style.opacity = '1';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
if (!previewLoading) {
|
||||
event.currentTarget.style.opacity = '0.6';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewLoading ? '⏳' : '👁'}
|
||||
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CodeCopyButton text={displayText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SyntaxHighlighter
|
||||
style={darkMode ? vscDarkPlus as any : vs as any}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
|
||||
maxHeight: expanded ? 'none' : (isLongCode ? 300 : 'none'),
|
||||
overflowY: expanded ? 'auto' : 'hidden',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
{!expanded && isLongCode && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 60,
|
||||
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 8,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
|
||||
展开全部代码
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{expanded && isLongCode && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 0',
|
||||
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)',
|
||||
cursor: 'pointer',
|
||||
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
|
||||
}}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}>收起代码</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
|
||||
❌ {previewError}
|
||||
</div>
|
||||
)}
|
||||
{previewExpanded && previewData && previewData.length > 0 && (
|
||||
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
|
||||
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 预览结果({previewData.length} 行 × {previewCols.length} 列)</span>
|
||||
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}>收起 ▴</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'var(--gn-font-mono)' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{previewCols.map((column) => (
|
||||
<th key={column} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{previewCols.map((column) => (
|
||||
<td key={column} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row[column] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[column])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!previewExpanded && previewData && previewData.length > 0 && (
|
||||
<div
|
||||
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
|
||||
onClick={() => setPreviewExpanded(true)}
|
||||
>
|
||||
📊 查看结果({previewData.length} 行)▾
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIMessageCodeBlock: React.FC<AIMessageCodeBlockProps> = ({
|
||||
className,
|
||||
inline,
|
||||
children,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
activeConnectionConfig,
|
||||
activeConnectionId,
|
||||
activeDbName,
|
||||
}) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
if (!inline && match && match[1] === 'mermaid') {
|
||||
return <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
|
||||
}
|
||||
|
||||
if (!inline && match) {
|
||||
const codeText = String(children).replace(/\n$/, '');
|
||||
const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
|
||||
|
||||
return (
|
||||
<HighlightedCodeBlock
|
||||
language={match[1]}
|
||||
codeText={codeText}
|
||||
displayText={displayText}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeConnectionId={activeConnectionId}
|
||||
activeDbName={activeDbName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <code className={className}>{children}</code>;
|
||||
};
|
||||
@@ -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(
|
||||
<AIMessageMarkdown
|
||||
content={'```python\nprint("hello")\n```'}
|
||||
darkMode={false}
|
||||
overlayTheme={overlayTheme}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
<AIMessageMarkdown
|
||||
content={'```mermaid\ngraph TD;\nA-->B;\n```'}
|
||||
darkMode={false}
|
||||
overlayTheme={overlayTheme}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
<AIMessageMarkdown
|
||||
content={'普通 `inline` 代码'}
|
||||
darkMode={false}
|
||||
overlayTheme={overlayTheme}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
} finally {
|
||||
act(() => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement>(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 = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${error.message}</div>`;
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
}, [chart, darkMode]);
|
||||
|
||||
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
|
||||
};
|
||||
|
||||
const CodeCopyButton: React.FC<{ text: string }> = ({ text }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="ai-code-copy-btn"
|
||||
onClick={() => {
|
||||
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 ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
|
||||
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={() => {
|
||||
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'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>插入</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="立即执行(受 AI 安全策略管控)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={handleExecute}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.2s',
|
||||
padding: '0 4px',
|
||||
color: '#1677ff',
|
||||
}}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>执行</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AICodeBlock: React.FC<AICodeBlockProps> = ({
|
||||
className,
|
||||
inline,
|
||||
children,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
activeConnectionConfig,
|
||||
activeConnectionId,
|
||||
activeDbName,
|
||||
}) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
if (!inline && match && match[1] === 'mermaid') {
|
||||
return <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
|
||||
}
|
||||
|
||||
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<any[] | null>(null);
|
||||
const [previewCols, setPreviewCols] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div
|
||||
className="ai-code-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '6px 12px',
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
fontSize: 12,
|
||||
color: overlayTheme.mutedText,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--gn-font-mono)' }}>{match[1]}</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
{isSql && <CodeRunButton text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
|
||||
{isSelectQuery && activeConnectionConfig && (
|
||||
<Tooltip title="在聊天内预览查询结果(最多20行)">
|
||||
<span
|
||||
onClick={handleInlineExecute}
|
||||
style={{
|
||||
cursor: previewLoading ? 'wait' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: previewLoading ? 1 : 0.6,
|
||||
transition: 'opacity 0.2s',
|
||||
padding: '0 4px',
|
||||
color: '#faad14',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
if (!previewLoading) {
|
||||
event.currentTarget.style.opacity = '1';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
if (!previewLoading) {
|
||||
event.currentTarget.style.opacity = '0.6';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewLoading ? '⏳' : '👁'}
|
||||
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CodeCopyButton text={displayText} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SyntaxHighlighter
|
||||
style={darkMode ? vscDarkPlus as any : vs as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
|
||||
maxHeight: expanded ? 'none' : (isLongCode ? 300 : 'none'),
|
||||
overflowY: expanded ? 'auto' : 'hidden',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</SyntaxHighlighter>
|
||||
|
||||
{!expanded && isLongCode && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 60,
|
||||
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 8,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
|
||||
展开全部代码
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{expanded && isLongCode && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 0',
|
||||
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)',
|
||||
cursor: 'pointer',
|
||||
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
|
||||
}}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}>收起代码</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
|
||||
❌ {previewError}
|
||||
</div>
|
||||
)}
|
||||
{previewExpanded && previewData && previewData.length > 0 && (
|
||||
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
|
||||
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 预览结果({previewData.length} 行 × {previewCols.length} 列)</span>
|
||||
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}>收起 ▴</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'var(--gn-font-mono)' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{previewCols.map((column) => (
|
||||
<th key={column} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{previewCols.map((column) => (
|
||||
<td key={column} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row[column] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[column])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!previewExpanded && previewData && previewData.length > 0 && (
|
||||
<div
|
||||
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
|
||||
onClick={() => setPreviewExpanded(true)}
|
||||
>
|
||||
📊 查看结果({previewData.length} 行)▾
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <code className={className}>{children}</code>;
|
||||
};
|
||||
|
||||
export const AIMessageMarkdown: React.FC<AIMessageMarkdownProps> = React.memo(({
|
||||
content,
|
||||
darkMode,
|
||||
@@ -417,7 +29,7 @@ export const AIMessageMarkdown: React.FC<AIMessageMarkdownProps> = React.memo(({
|
||||
const components = React.useMemo(() => ({
|
||||
code({ inline, className, children }: any) {
|
||||
return (
|
||||
<AICodeBlock
|
||||
<AIMessageCodeBlock
|
||||
inline={inline}
|
||||
className={className}
|
||||
darkMode={darkMode}
|
||||
@@ -427,7 +39,7 @@ export const AIMessageMarkdown: React.FC<AIMessageMarkdownProps> = React.memo(({
|
||||
activeDbName={activeDbName}
|
||||
>
|
||||
{children}
|
||||
</AICodeBlock>
|
||||
</AIMessageCodeBlock>
|
||||
);
|
||||
},
|
||||
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
|
||||
|
||||
Reference in New Issue
Block a user