mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
♻️ refactor(ai-chat): 拆分洞察与历史模式视图
- 提取 AI 面板的自动洞察与内联历史为独立展示组件 - 保持会话切换与发送主链留在 AIChatPanel 中 - 补充模式内容与消息边界相关回归测试 - 完成 vitest、生产构建与本地预览切换验证
This commit is contained in:
@@ -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}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
69
frontend/src/components/ai/AIChatPanelModeContent.test.tsx
Normal file
69
frontend/src/components/ai/AIChatPanelModeContent.test.tsx
Normal 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('旧会话');
|
||||
});
|
||||
});
|
||||
98
frontend/src/components/ai/AIChatPanelModeContent.tsx
Normal file
98
frontend/src/components/ai/AIChatPanelModeContent.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user