♻️ refactor(ai-message): 拆分 Markdown 代码块渲染逻辑

This commit is contained in:
Syngnat
2026-06-09 13:08:01 +08:00
parent 17a3d72852
commit 8543f1dc65
3 changed files with 486 additions and 393 deletions

View 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>;
};

View File

@@ -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();
});
}
});
});

View File

@@ -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]);