♻️ refactor(ai-message): 拆分消息气泡渲染并补齐展示测试

- 抽离 AIMessageMarkdown 与 AIMessageStatusBlocks,拆分代码块、思考态和探针状态渲染职责
- 优化探针结果查找链路,使用 Map 减少消息渲染时的重复扫描
- 新增消息气泡与 Markdown 代码块测试,并完成 build 与浏览器验证
This commit is contained in:
Syngnat
2026-06-08 06:45:40 +08:00
parent 67dd178166
commit 9d3c77755d
5 changed files with 1145 additions and 816 deletions

View 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

View File

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

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

View File

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