♻️ refactor(ai-chat): 拆分洞察与历史模式视图

- 提取 AI 面板的自动洞察与内联历史为独立展示组件
- 保持会话切换与发送主链留在 AIChatPanel 中
- 补充模式内容与消息边界相关回归测试
- 完成 vitest、生产构建与本地预览切换验证
This commit is contained in:
Syngnat
2026-06-08 15:50:40 +08:00
parent 02afeba564
commit 0312dfbb16
4 changed files with 188 additions and 49 deletions

View File

@@ -37,6 +37,8 @@ describe('AIChatPanel message render isolation', () => {
it('keeps the v2 history mode sorted by the latest updated session first', () => {
expect(source).toContain('const orderedAISessions = useMemo(');
expect(source).toContain('right.updatedAt - left.updatedAt');
expect(source).toContain('const sessions = orderedAISessions.slice(0, 8);');
expect(source).toContain('const panelHistorySessions = useMemo(');
expect(source).toContain('orderedAISessions.slice(0, 8)');
expect(source).toContain('sessions={panelHistorySessions}');
});
});

View File

@@ -12,7 +12,7 @@ import type {
JVMAIPlanContext,
JVMDiagnosticPlanContext,
} from '../types';
import { DatabaseOutlined, DownOutlined, HistoryOutlined, TableOutlined, WarningOutlined } from '@ant-design/icons';
import { DownOutlined } from '@ant-design/icons';
import './AIChatPanel.css';
import { AIChatHeader } from './ai/AIChatHeader';
@@ -20,6 +20,7 @@ import { AIChatWelcome } from './ai/AIChatWelcome';
import { AIMessageBubble } from './ai/AIMessageBubble';
import { AIChatInput } from './ai/AIChatInput';
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import AIChatPanelModeContent, { type AIChatInsightItem } from './ai/AIChatPanelModeContent';
import type { AIComposerNotice } from '../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
@@ -1664,7 +1665,7 @@ SELECT * FROM users WHERE status = 1;
const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`);
}, [activeContext?.connectionId, activeContext?.dbName, aiContexts]);
const aiInsights = useMemo(() => {
const aiInsights = useMemo<AIChatInsightItem[]>(() => {
const recentLogs = sqlLogs.slice(0, 24);
const slowest = recentLogs
.filter((log) => log.status === 'success')
@@ -1697,30 +1698,10 @@ SELECT * FROM users WHERE status = 1;
},
];
}, [contextTableNames, sqlLogs]);
const renderPanelHistoryList = () => {
const sessions = orderedAISessions.slice(0, 8);
if (sessions.length === 0) {
return <div className="gn-v2-ai-empty-note"></div>;
}
return sessions.map((session) => (
<button
key={session.id}
type="button"
className={`gn-v2-ai-history-card${session.id === sid ? ' is-active' : ''}`}
onClick={() => {
setAIActiveSessionId(session.id);
setActivePanelMode('chat');
}}
>
<span>
<HistoryOutlined />
<strong>{session.title || '新对话'}</strong>
</span>
<small>{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</small>
</button>
));
};
const panelHistorySessions = useMemo(
() => orderedAISessions.slice(0, 8),
[orderedAISessions],
);
const effectivePanelMode = isV2Ui ? activePanelMode : 'chat';
return (
@@ -1827,28 +1808,17 @@ SELECT * FROM users WHERE status = 1;
)
)}
{effectivePanelMode === 'insights' && (
<div className="gn-v2-ai-insights-list">
{aiInsights.map((item) => (
<div className={`gn-v2-ai-insight-card tone-${item.tone}`} key={item.title}>
<span className="gn-v2-ai-insight-icon">
{item.tone === 'warn' ? <WarningOutlined /> : item.tone === 'accent' ? <DatabaseOutlined /> : <TableOutlined />}
</span>
<div>
<strong>{item.title}</strong>
<p>{item.body}</p>
</div>
</div>
))}
</div>
)}
{effectivePanelMode === 'history' && (
<div className="gn-v2-ai-history-list">
{renderPanelHistoryList()}
</div>
)}
<AIChatPanelModeContent
mode={effectivePanelMode}
insights={aiInsights}
sessions={panelHistorySessions}
activeSessionId={sid}
onSelectSession={(sessionId) => {
setAIActiveSessionId(sessionId);
setActivePanelMode('chat');
}}
/>
<div ref={messagesEndRef} />
</div>

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIChatPanelModeContent from './AIChatPanelModeContent';
describe('AIChatPanelModeContent', () => {
it('renders insight cards for the automatic insights mode', () => {
const markup = renderToStaticMarkup(
<AIChatPanelModeContent
mode="insights"
insights={[
{
tone: 'info',
title: '已关联 3 张表',
body: '当前对话会带上 orders、customers、products 的结构上下文。',
},
{
tone: 'warn',
title: '2 条最近查询失败',
body: 'Unknown column foo',
},
]}
sessions={[]}
activeSessionId="session-1"
onSelectSession={() => {}}
/>,
);
expect(markup).toContain('gn-v2-ai-insight-card tone-info');
expect(markup).toContain('已关联 3 张表');
expect(markup).toContain('2 条最近查询失败');
expect(markup).toContain('Unknown column foo');
});
it('renders an empty state when there is no inline history session', () => {
const markup = renderToStaticMarkup(
<AIChatPanelModeContent
mode="history"
insights={[]}
sessions={[]}
activeSessionId="session-1"
onSelectSession={() => {}}
/>,
);
expect(markup).toContain('gn-v2-ai-empty-note');
expect(markup).toContain('暂无历史会话');
});
it('marks the active inline history session', () => {
const markup = renderToStaticMarkup(
<AIChatPanelModeContent
mode="history"
insights={[]}
sessions={[
{ id: 'session-1', title: '当前会话', updatedAt: 1710000000000 },
{ id: 'session-2', title: '旧会话', updatedAt: 1700000000000 },
]}
activeSessionId="session-1"
onSelectSession={() => {}}
/>,
);
expect(markup).toContain('gn-v2-ai-history-card is-active');
expect(markup).toContain('当前会话');
expect(markup).toContain('旧会话');
});
});

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { DatabaseOutlined, HistoryOutlined, TableOutlined, WarningOutlined } from '@ant-design/icons';
export type AIChatPanelMode = 'chat' | 'insights' | 'history';
export interface AIChatInsightItem {
tone: 'info' | 'accent' | 'warn';
title: string;
body: string;
}
export interface AIChatInlineHistorySession {
id: string;
title: string;
updatedAt: number;
}
interface AIChatPanelModeContentProps {
mode: AIChatPanelMode;
insights: AIChatInsightItem[];
sessions: AIChatInlineHistorySession[];
activeSessionId: string;
onSelectSession: (sessionId: string) => void;
}
const renderInsightIcon = (tone: AIChatInsightItem['tone']) => {
if (tone === 'warn') {
return <WarningOutlined />;
}
if (tone === 'accent') {
return <DatabaseOutlined />;
}
return <TableOutlined />;
};
const AIChatPanelModeContent: React.FC<AIChatPanelModeContentProps> = ({
mode,
insights,
sessions,
activeSessionId,
onSelectSession,
}) => {
if (mode === 'insights') {
return (
<div className="gn-v2-ai-insights-list">
{insights.map((item) => (
<div className={`gn-v2-ai-insight-card tone-${item.tone}`} key={item.title}>
<span className="gn-v2-ai-insight-icon">{renderInsightIcon(item.tone)}</span>
<div>
<strong>{item.title}</strong>
<p>{item.body}</p>
</div>
</div>
))}
</div>
);
}
if (mode === 'history') {
if (sessions.length === 0) {
return (
<div className="gn-v2-ai-history-list">
<div className="gn-v2-ai-empty-note"></div>
</div>
);
}
return (
<div className="gn-v2-ai-history-list">
{sessions.map((session) => (
<button
key={session.id}
type="button"
className={`gn-v2-ai-history-card${session.id === activeSessionId ? ' is-active' : ''}`}
onClick={() => onSelectSession(session.id)}
>
<span>
<HistoryOutlined />
<strong>{session.title || '新对话'}</strong>
</span>
<small>
{new Date(session.updatedAt).toLocaleString(undefined, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</small>
</button>
))}
</div>
);
}
return null;
};
export default AIChatPanelModeContent;