mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 10:59:41 +08:00
♻️ refactor(ai-message): 拆分消息气泡渲染并补齐展示测试
- 抽离 AIMessageMarkdown 与 AIMessageStatusBlocks,拆分代码块、思考态和探针状态渲染职责 - 优化探针结果查找链路,使用 Map 减少消息渲染时的重复扫描 - 新增消息气泡与 Markdown 代码块测试,并完成 build 与浏览器验证
This commit is contained in:
55
frontend/src/components/ai/AIMessageBubble.test.tsx
Normal file
55
frontend/src/components/ai/AIMessageBubble.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AIMessageBubble } from './AIMessageBubble';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
describe('AIMessageBubble', () => {
|
||||
it('renders thinking, tool progress and raw error actions after extracting status blocks', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIMessageBubble
|
||||
msg={{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '这里是诊断结论。',
|
||||
thinking: '先看连接,再看表结构。',
|
||||
rawError: 'driver timeout',
|
||||
timestamp: Date.now(),
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_foreign_keys',
|
||||
arguments: '{}',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
textColor="#1f2937"
|
||||
onEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
allMessages={[
|
||||
{
|
||||
id: 'tool-result-1',
|
||||
role: 'tool',
|
||||
content: '[{\"fk\":\"orders.customer_id\"}]',
|
||||
timestamp: Date.now(),
|
||||
tool_call_id: 'tool-1',
|
||||
tool_name: 'get_foreign_keys',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('GoNavi AI');
|
||||
expect(markup).toContain('思考过程');
|
||||
expect(markup).toContain('梳理外键关系');
|
||||
expect(markup).toContain('复制报错原文');
|
||||
expect(markup).toContain('数据探针执行完毕');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AIMessageMarkdown } from './AIMessageMarkdown';
|
||||
import { buildOverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme';
|
||||
|
||||
describe('AIMessageMarkdown', () => {
|
||||
it('keeps SQL code block actions after extracting markdown rendering', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIMessageMarkdown
|
||||
content={'```sql\nSELECT * FROM users;\n```'}
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
activeConnectionConfig={{ type: 'mysql', driver: 'mysql' }}
|
||||
activeConnectionId="conn-1"
|
||||
activeDbName="demo"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('复制代码');
|
||||
expect(markup).toContain('插入');
|
||||
expect(markup).toContain('执行');
|
||||
expect(markup).toContain('预览');
|
||||
});
|
||||
});
|
||||
440
frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx
Normal file
440
frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Tooltip, message } from 'antd';
|
||||
import { CheckOutlined, CopyOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
|
||||
const remarkPlugins = [remarkGfm];
|
||||
|
||||
interface AIMessageMarkdownProps {
|
||||
content: string;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
activeConnectionConfig?: any;
|
||||
activeConnectionId?: string;
|
||||
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,
|
||||
overlayTheme,
|
||||
activeConnectionConfig,
|
||||
activeConnectionId,
|
||||
activeDbName,
|
||||
}) => {
|
||||
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
|
||||
const components = React.useMemo(() => ({
|
||||
code({ inline, className, children }: any) {
|
||||
return (
|
||||
<AICodeBlock
|
||||
inline={inline}
|
||||
className={className}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeConnectionId={activeConnectionId}
|
||||
activeDbName={activeDbName}
|
||||
>
|
||||
{children}
|
||||
</AICodeBlock>
|
||||
);
|
||||
},
|
||||
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ApiOutlined, CaretDownOutlined, CaretRightOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { AIChatMessage, AIToolCall } from '../../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface AIThinkingBlockProps {
|
||||
displayThinking: string;
|
||||
isTyping: boolean;
|
||||
isGlobalLoading: boolean;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
interface AIToolCallingBlockProps {
|
||||
toolCalls: AIToolCall[];
|
||||
loading: boolean;
|
||||
allMessages: AIChatMessage[];
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
get_connections: '获取可用连接信息',
|
||||
get_databases: '扫描数据库列表',
|
||||
get_tables: '分析表结构信息',
|
||||
get_columns: '核对真实字段定义',
|
||||
get_indexes: '检查索引定义',
|
||||
get_foreign_keys: '梳理外键关系',
|
||||
get_triggers: '检查触发器逻辑',
|
||||
get_table_ddl: '提取建表语句',
|
||||
execute_sql: '执行只读 SQL 验证',
|
||||
};
|
||||
|
||||
const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage; darkMode: boolean; overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => {
|
||||
const [toolExpanded, setToolExpanded] = useState(false);
|
||||
const charCount = resultMsg.content ? resultMsg.content.length : 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
}}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 6, fontSize: 12, color: overlayTheme.mutedText }}
|
||||
onClick={() => setToolExpanded((prev) => !prev)}
|
||||
>
|
||||
{toolExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||
<ApiOutlined style={{ color: '#1677ff' }} />
|
||||
<span>探针执行结果 (<span style={{ fontFamily: 'var(--gn-font-mono)', color: overlayTheme.iconColor }}>{resultMsg.tool_name || 'unknown'}</span>)</span>
|
||||
<span style={{ fontSize: 11, marginLeft: 8, opacity: 0.6 }}>{charCount > 0 ? `${charCount} 个字符` : '无数据'}</span>
|
||||
</div>
|
||||
{toolExpanded && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, fontFamily: 'var(--gn-font-mono)', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 300, overflowY: 'auto', background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.03)', padding: 8, borderRadius: 6 }}>
|
||||
{resultMsg.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIThinkingBlock: React.FC<AIThinkingBlockProps> = ({
|
||||
displayThinking,
|
||||
isTyping,
|
||||
isGlobalLoading,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
hasContent,
|
||||
}) => {
|
||||
const isActivelyThinking = isGlobalLoading && !hasContent;
|
||||
const [expanded, setExpanded] = useState(isActivelyThinking);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActivelyThinking) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [isActivelyThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGlobalLoading) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [isGlobalLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && isTyping && contentRef.current) {
|
||||
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||
}
|
||||
}, [displayThinking, expanded, isTyping]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: hasContent ? 8 : 0,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
cursor: 'pointer',
|
||||
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
|
||||
fontSize: 12,
|
||||
color: overlayTheme.mutedText,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10 }}>▶</span>
|
||||
<span>💭 思考过程</span>
|
||||
{isActivelyThinking && <span style={{ fontSize: 10, color: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }}>思考中...</span>}
|
||||
{!isActivelyThinking && <span style={{ fontSize: 10, opacity: 0.5 }}>({displayThinking.length} 字)</span>}
|
||||
</div>
|
||||
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div ref={contentRef} style={{
|
||||
padding: expanded ? '8px 12px' : '0 12px',
|
||||
borderLeft: '3px solid #8b5cf6',
|
||||
margin: '0 8px 8px',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
color: overlayTheme.mutedText,
|
||||
fontStyle: 'italic',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{displayThinking}
|
||||
{isTyping && <span className="ai-blinking-cursor" style={{ background: '#8b5cf6', marginLeft: 4, width: 6, height: 12, display: 'inline-block', verticalAlign: 'middle', opacity: 0.8 }} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIToolCallingBlock: React.FC<AIToolCallingBlockProps> = ({
|
||||
toolCalls,
|
||||
loading,
|
||||
allMessages,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
hasContent,
|
||||
}) => {
|
||||
const toolResultsById = useMemo(() => {
|
||||
return new Map(
|
||||
allMessages
|
||||
.filter((message) => message.role === 'tool' && message.tool_call_id)
|
||||
.map((message) => [message.tool_call_id as string, message]),
|
||||
);
|
||||
}, [allMessages]);
|
||||
const allDone = toolCalls.every((toolCall) => toolResultsById.has(toolCall.id));
|
||||
const [expanded, setExpanded] = useState(!allDone && loading);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDone || !loading) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [allDone, loading]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.025)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
|
||||
marginTop: hasContent ? 12 : 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.titleText, fontWeight: 500 }}>
|
||||
{!allDone && loading ? (
|
||||
<div className="ai-spinning-ring" />
|
||||
) : (
|
||||
<CheckOutlined style={{ color: '#10b981' }} />
|
||||
)}
|
||||
<span>{!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${toolCalls.length} 项)`}</span>
|
||||
</div>
|
||||
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10, color: overlayTheme.mutedText }}>▶</span>
|
||||
</div>
|
||||
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div style={{ padding: expanded ? '4px 12px 12px' : '0 12px' }}>
|
||||
{toolCalls.map((toolCall) => {
|
||||
const resultMsg = toolResultsById.get(toolCall.id);
|
||||
const isDone = Boolean(resultMsg);
|
||||
const actionName = TOOL_ACTION_LABELS[toolCall.function.name] || toolCall.function.name;
|
||||
return (
|
||||
<div key={toolCall.id} style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
marginTop: 6,
|
||||
paddingLeft: 8,
|
||||
borderLeft: `2px solid ${isDone ? '#10b981' : (loading ? '#1677ff' : overlayTheme.shellBorder)}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isDone
|
||||
? <CheckOutlined style={{ color: '#10b981', fontSize: 11 }} />
|
||||
: (loading ? <div className="ai-spinning-ring" style={{ width: 10, height: 10, borderWidth: 1.5 }} /> : <ApiOutlined style={{ color: overlayTheme.mutedText, fontSize: 11 }} />)}
|
||||
<span style={{ color: isDone ? overlayTheme.mutedText : overlayTheme.titleText }}>{actionName}</span>
|
||||
</div>
|
||||
{resultMsg && <AIToolResultItem resultMsg={resultMsg} darkMode={darkMode} overlayTheme={overlayTheme} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user