mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-02 00:21:27 +08:00
♻️ refactor(ai-chat): 拆分面板会话视图与派生状态逻辑
- 抽离 AIChatPanelConversationView 承载欢迎态、历史态和洞察态渲染 - 下沉连接推断、上下文统计和会话裁剪等派生逻辑到独立模块 - 补充守卫测试并验证 AI 面板定向测试、构建和真实页面切换
This commit is contained in:
@@ -3,18 +3,21 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
|
||||
const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8');
|
||||
const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8');
|
||||
const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8');
|
||||
const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8');
|
||||
const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8');
|
||||
|
||||
describe('AIChatPanel message render isolation', () => {
|
||||
it('keeps per-message render failures scoped to the broken bubble', () => {
|
||||
expect(source).toContain("import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary';");
|
||||
expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';");
|
||||
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
|
||||
expect(source).toContain('[AI Message Render Error]');
|
||||
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
|
||||
expect(boundarySource).toContain('这条 AI 消息渲染失败,已自动隔离');
|
||||
expect(source).toContain('__gonaviLastAIMessageRenderError');
|
||||
expect(source).toContain('<AIMessageRenderBoundary');
|
||||
expect(source).toContain('onDeleteMessage={handleDeleteMessage}');
|
||||
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
|
||||
expect(conversationViewSource).toContain('onDeleteMessage={onDeleteMessage}');
|
||||
});
|
||||
|
||||
it('loads user prompt settings and appends them as system messages', () => {
|
||||
@@ -55,7 +58,9 @@ describe('AIChatPanel message render isolation', () => {
|
||||
expect(source).toContain('const orderedAISessions = useMemo(');
|
||||
expect(source).toContain('right.updatedAt - left.updatedAt');
|
||||
expect(source).toContain('const panelHistorySessions = useMemo(');
|
||||
expect(source).toContain('orderedAISessions.slice(0, 8)');
|
||||
expect(source).toContain('buildAIChatInlineHistorySessions(orderedAISessions)');
|
||||
expect(derivedStateSource).toContain('export const buildAIChatInlineHistorySessions');
|
||||
expect(derivedStateSource).toContain('sessions.slice(0, limit)');
|
||||
expect(source).toContain('sessions={panelHistorySessions}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,16 +13,12 @@ import type {
|
||||
JVMAIPlanContext,
|
||||
JVMDiagnosticPlanContext,
|
||||
} from '../types';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import './AIChatPanel.css';
|
||||
|
||||
import { AIChatHeader } from './ai/AIChatHeader';
|
||||
import { AIChatWelcome } from './ai/AIChatWelcome';
|
||||
import { AIMessageBubble } from './ai/AIMessageBubble';
|
||||
import { AIChatInput } from './ai/AIChatInput';
|
||||
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary';
|
||||
import AIChatPanelModeContent, { type AIChatInsightItem } from './ai/AIChatPanelModeContent';
|
||||
import AIChatPanelConversationView from './ai/AIChatPanelConversationView';
|
||||
import type { AIComposerNotice, AIComposerNoticeAction } from '../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
@@ -42,6 +38,14 @@ import {
|
||||
executeLocalAIToolCall,
|
||||
type AIToolContextEntry,
|
||||
} from './ai/aiLocalToolExecutor';
|
||||
import {
|
||||
buildAIChatInlineHistorySessions,
|
||||
buildAIChatInsights,
|
||||
calculateAIContextUsageChars,
|
||||
collectAIChatContextTableNames,
|
||||
inferAIChatConnectionContext,
|
||||
resolveAIChatPanelMode,
|
||||
} from './ai/aiChatPanelDerivedState';
|
||||
import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness';
|
||||
import { buildAISystemContextMessages } from './ai/aiSystemContextMessages';
|
||||
|
||||
@@ -1247,32 +1251,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
};
|
||||
}, [isResizing, isV2Ui, onWidthChange]);
|
||||
|
||||
// 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配(useMemo 缓存,避免每帧重算)
|
||||
const { inferredConnectionId, inferredDbName } = useMemo(() => {
|
||||
let connId = activeContext?.connectionId;
|
||||
let dbName = activeContext?.dbName;
|
||||
|
||||
if (!connId || !dbName) {
|
||||
const allMsgText = messages.map(m => m.content || '').join(' ');
|
||||
let bestMatch: { connectionId: string; dbName: string } | null = null;
|
||||
let bestScore = 0;
|
||||
for (const entry of toolContextMapRef.current.values()) {
|
||||
let score = 0;
|
||||
for (const table of entry.tables) {
|
||||
if (allMsgText.includes(table)) score++;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = { connectionId: entry.connectionId, dbName: entry.dbName };
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
if (!connId) connId = bestMatch.connectionId;
|
||||
if (!dbName) dbName = bestMatch.dbName;
|
||||
}
|
||||
}
|
||||
return { inferredConnectionId: connId, inferredDbName: dbName };
|
||||
}, [activeContext?.connectionId, activeContext?.dbName, messages.length]);
|
||||
const { inferredConnectionId, inferredDbName } = useMemo(
|
||||
() => inferAIChatConnectionContext({
|
||||
activeConnectionId: activeContext?.connectionId,
|
||||
activeDbName: activeContext?.dbName,
|
||||
messages,
|
||||
toolContextEntries: toolContextMapRef.current.values(),
|
||||
}),
|
||||
[activeContext?.connectionId, activeContext?.dbName, messages],
|
||||
);
|
||||
|
||||
// useMemo 缓存:避免内联闭包击穿子组件 memo
|
||||
const handleDeleteMessage = useCallback((id: string) => deleteAIChatMessage(sid, id), [sid, deleteAIChatMessage]);
|
||||
@@ -1294,51 +1281,33 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const connection = connections.find(c => c.id === inferredConnectionId);
|
||||
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
|
||||
}, [inferredConnectionId, connections]);
|
||||
const contextUsageChars = useMemo(() =>
|
||||
messages.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
|
||||
[messages]);
|
||||
const contextTableNames = useMemo(() => {
|
||||
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<AIChatInsightItem[]>(() => {
|
||||
const recentLogs = sqlLogs.slice(0, 24);
|
||||
const slowest = recentLogs
|
||||
.filter((log) => log.status === 'success')
|
||||
.sort((a, b) => b.duration - a.duration)[0];
|
||||
const errors = recentLogs.filter((log) => log.status === 'error');
|
||||
const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length;
|
||||
const contextCount = contextTableNames.length;
|
||||
return [
|
||||
{
|
||||
tone: 'info',
|
||||
title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构',
|
||||
body: contextCount > 0
|
||||
? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。`
|
||||
: '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。',
|
||||
},
|
||||
{
|
||||
tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent',
|
||||
title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本',
|
||||
body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。',
|
||||
},
|
||||
{
|
||||
tone: errors.length > 0 ? 'warn' : 'info',
|
||||
title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常',
|
||||
body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'),
|
||||
},
|
||||
{
|
||||
tone: writeCount > 0 ? 'warn' : 'accent',
|
||||
title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主',
|
||||
body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。',
|
||||
},
|
||||
];
|
||||
}, [contextTableNames, sqlLogs]);
|
||||
const contextUsageChars = useMemo(
|
||||
() => calculateAIContextUsageChars(messages),
|
||||
[messages],
|
||||
);
|
||||
const contextTableNames = useMemo(
|
||||
() => collectAIChatContextTableNames({
|
||||
aiContexts,
|
||||
activeConnectionId: activeContext?.connectionId,
|
||||
activeDbName: activeContext?.dbName,
|
||||
}),
|
||||
[activeContext?.connectionId, activeContext?.dbName, aiContexts],
|
||||
);
|
||||
const aiInsights = useMemo(
|
||||
() => buildAIChatInsights({
|
||||
contextTableNames,
|
||||
sqlLogs,
|
||||
}),
|
||||
[contextTableNames, sqlLogs],
|
||||
);
|
||||
const panelHistorySessions = useMemo(
|
||||
() => orderedAISessions.slice(0, 8),
|
||||
() => buildAIChatInlineHistorySessions(orderedAISessions),
|
||||
[orderedAISessions],
|
||||
);
|
||||
const effectivePanelMode = isV2Ui ? activePanelMode : 'chat';
|
||||
const effectivePanelMode = useMemo(
|
||||
() => resolveAIChatPanelMode(isV2Ui, activePanelMode),
|
||||
[activePanelMode, isV2Ui],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
@@ -1392,88 +1361,44 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="ai-chat-messages" onScroll={handleScrollMessages}>
|
||||
{effectivePanelMode === 'chat' && (
|
||||
messages.length === 0 ? (
|
||||
<AIChatWelcome
|
||||
overlayTheme={overlayTheme}
|
||||
quickActionBg={quickActionBg}
|
||||
quickActionBorder={quickActionBorder}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
onQuickAction={(prompt: string, autoSend?: boolean) => {
|
||||
setInput(prompt);
|
||||
if (autoSend) {
|
||||
// Use setTimeout to let setInput render, then trigger send
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) el.focus();
|
||||
// Dispatch a synthetic enter to trigger handleSend
|
||||
// Simpler: just call handleSend directly with the prompt
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
contextTableNames={contextTableNames}
|
||||
isV2Ui={isV2Ui}
|
||||
/>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<AIMessageRenderBoundary
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onError={handleMessageRenderError}
|
||||
>
|
||||
<AIMessageBubble
|
||||
msg={msg}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
textColor={textColor}
|
||||
onEdit={handleEditMessage}
|
||||
onRetry={handleRetryMessage}
|
||||
onDelete={handleDeleteMessage}
|
||||
activeConnectionId={inferredConnectionId}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeDbName={inferredDbName}
|
||||
allMessages={messages}
|
||||
/>
|
||||
</AIMessageRenderBoundary>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
<AIChatPanelModeContent
|
||||
mode={effectivePanelMode}
|
||||
insights={aiInsights}
|
||||
sessions={panelHistorySessions}
|
||||
activeSessionId={sid}
|
||||
onSelectSession={(sessionId) => {
|
||||
setAIActiveSessionId(sessionId);
|
||||
setActivePanelMode('chat');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{showScrollBottom && (
|
||||
<div
|
||||
onClick={scrollToMessagesBottom}
|
||||
style={{
|
||||
position: 'absolute', bottom: 120, right: 20, width: 32, height: 32, borderRadius: '50%',
|
||||
background: darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||
color: textColor, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', zIndex: 10, transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; }}
|
||||
>
|
||||
<DownOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanelConversationView
|
||||
mode={effectivePanelMode}
|
||||
messages={messages}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
quickActionBg={quickActionBg}
|
||||
quickActionBorder={quickActionBorder}
|
||||
showScrollBottom={showScrollBottom}
|
||||
contextTableNames={contextTableNames}
|
||||
isV2Ui={isV2Ui}
|
||||
insights={aiInsights}
|
||||
sessions={panelHistorySessions}
|
||||
activeSessionId={sid}
|
||||
activeConnectionId={inferredConnectionId}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeDbName={inferredDbName}
|
||||
messagesEndRef={messagesEndRef}
|
||||
onScrollMessages={handleScrollMessages}
|
||||
onQuickAction={(prompt: string, autoSend?: boolean) => {
|
||||
setInput(prompt);
|
||||
if (autoSend) {
|
||||
window.setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
onSelectSession={(sessionId) => {
|
||||
setAIActiveSessionId(sessionId);
|
||||
setActivePanelMode('chat');
|
||||
}}
|
||||
onEditMessage={handleEditMessage}
|
||||
onRetryMessage={handleRetryMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onMessageRenderError={handleMessageRenderError}
|
||||
onScrollBottom={scrollToMessagesBottom}
|
||||
/>
|
||||
|
||||
<AIChatInput
|
||||
input={input}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { createRef } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AIChatPanelConversationView from './AIChatPanelConversationView';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
describe('AIChatPanelConversationView', () => {
|
||||
it('renders the welcome state when the chat mode has no messages', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatPanelConversationView
|
||||
mode="chat"
|
||||
messages={[]}
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
textColor="#0f172a"
|
||||
mutedColor="#64748b"
|
||||
quickActionBg="rgba(255,255,255,0.8)"
|
||||
quickActionBorder="1px solid rgba(0,0,0,0.06)"
|
||||
showScrollBottom={false}
|
||||
contextTableNames={['sales.orders']}
|
||||
isV2Ui
|
||||
insights={[]}
|
||||
sessions={[]}
|
||||
activeSessionId="session-1"
|
||||
activeConnectionId={undefined}
|
||||
activeConnectionConfig={undefined}
|
||||
activeDbName={undefined}
|
||||
messagesEndRef={createRef<HTMLDivElement>()}
|
||||
onScrollMessages={() => {}}
|
||||
onQuickAction={() => {}}
|
||||
onSelectSession={() => {}}
|
||||
onEditMessage={() => {}}
|
||||
onRetryMessage={() => {}}
|
||||
onDeleteMessage={() => {}}
|
||||
onMessageRenderError={() => {}}
|
||||
onScrollBottom={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('你好,我是 GoNavi AI');
|
||||
expect(markup).toContain('已自动关联');
|
||||
expect(markup).toContain('生成 SQL');
|
||||
});
|
||||
|
||||
it('renders inline history mode content and the scroll-bottom affordance', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatPanelConversationView
|
||||
mode="history"
|
||||
messages={[]}
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
textColor="#0f172a"
|
||||
mutedColor="#64748b"
|
||||
quickActionBg="rgba(255,255,255,0.8)"
|
||||
quickActionBorder="1px solid rgba(0,0,0,0.06)"
|
||||
showScrollBottom
|
||||
contextTableNames={[]}
|
||||
isV2Ui
|
||||
insights={[]}
|
||||
sessions={[
|
||||
{ id: 'session-1', title: '当前会话', updatedAt: 1710000000000 },
|
||||
{ id: 'session-2', title: '旧会话', updatedAt: 1700000000000 },
|
||||
]}
|
||||
activeSessionId="session-1"
|
||||
activeConnectionId={undefined}
|
||||
activeConnectionConfig={undefined}
|
||||
activeDbName={undefined}
|
||||
messagesEndRef={createRef<HTMLDivElement>()}
|
||||
onScrollMessages={() => {}}
|
||||
onQuickAction={() => {}}
|
||||
onSelectSession={() => {}}
|
||||
onEditMessage={() => {}}
|
||||
onRetryMessage={() => {}}
|
||||
onDeleteMessage={() => {}}
|
||||
onMessageRenderError={() => {}}
|
||||
onScrollBottom={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('gn-v2-ai-history-card is-active');
|
||||
expect(markup).toContain('当前会话');
|
||||
expect(markup).toContain('旧会话');
|
||||
expect(markup).toContain('down');
|
||||
});
|
||||
});
|
||||
162
frontend/src/components/ai/AIChatPanelConversationView.tsx
Normal file
162
frontend/src/components/ai/AIChatPanelConversationView.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { RpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIChatMessage } from '../../types';
|
||||
import { AIChatWelcome } from './AIChatWelcome';
|
||||
import { AIMessageBubble } from './AIMessageBubble';
|
||||
import AIMessageRenderBoundary from './AIMessageRenderBoundary';
|
||||
import AIChatPanelModeContent, {
|
||||
type AIChatInlineHistorySession,
|
||||
type AIChatInsightItem,
|
||||
type AIChatPanelMode,
|
||||
} from './AIChatPanelModeContent';
|
||||
|
||||
interface AIChatPanelConversationViewProps {
|
||||
mode: AIChatPanelMode;
|
||||
messages: AIChatMessage[];
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
textColor: string;
|
||||
mutedColor: string;
|
||||
quickActionBg: string;
|
||||
quickActionBorder: string;
|
||||
showScrollBottom: boolean;
|
||||
contextTableNames: string[];
|
||||
isV2Ui: boolean;
|
||||
insights: AIChatInsightItem[];
|
||||
sessions: AIChatInlineHistorySession[];
|
||||
activeSessionId: string;
|
||||
activeConnectionId?: string;
|
||||
activeConnectionConfig?: RpcConnectionConfig;
|
||||
activeDbName?: string;
|
||||
messagesEndRef: React.Ref<HTMLDivElement>;
|
||||
onScrollMessages: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||
onQuickAction: (prompt: string, autoSend?: boolean) => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onEditMessage: (message: AIChatMessage) => void;
|
||||
onRetryMessage: (message: AIChatMessage) => void;
|
||||
onDeleteMessage: (id: string) => void;
|
||||
onMessageRenderError: (error: Error, errorInfo: React.ErrorInfo, message: AIChatMessage) => void;
|
||||
onScrollBottom: () => void;
|
||||
}
|
||||
|
||||
const AIChatPanelConversationView: React.FC<AIChatPanelConversationViewProps> = ({
|
||||
mode,
|
||||
messages,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
textColor,
|
||||
mutedColor,
|
||||
quickActionBg,
|
||||
quickActionBorder,
|
||||
showScrollBottom,
|
||||
contextTableNames,
|
||||
isV2Ui,
|
||||
insights,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeConnectionId,
|
||||
activeConnectionConfig,
|
||||
activeDbName,
|
||||
messagesEndRef,
|
||||
onScrollMessages,
|
||||
onQuickAction,
|
||||
onSelectSession,
|
||||
onEditMessage,
|
||||
onRetryMessage,
|
||||
onDeleteMessage,
|
||||
onMessageRenderError,
|
||||
onScrollBottom,
|
||||
}) => (
|
||||
<>
|
||||
<div className="ai-chat-messages" onScroll={onScrollMessages}>
|
||||
{mode === 'chat' && (
|
||||
messages.length === 0 ? (
|
||||
<AIChatWelcome
|
||||
overlayTheme={overlayTheme}
|
||||
quickActionBg={quickActionBg}
|
||||
quickActionBorder={quickActionBorder}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
onQuickAction={onQuickAction}
|
||||
contextTableNames={contextTableNames}
|
||||
isV2Ui={isV2Ui}
|
||||
/>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<AIMessageRenderBoundary
|
||||
key={message.id}
|
||||
msg={message}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onError={onMessageRenderError}
|
||||
>
|
||||
<AIMessageBubble
|
||||
msg={message}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
textColor={textColor}
|
||||
onEdit={onEditMessage}
|
||||
onRetry={onRetryMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
activeConnectionId={activeConnectionId}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeDbName={activeDbName}
|
||||
allMessages={messages}
|
||||
/>
|
||||
</AIMessageRenderBoundary>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
<AIChatPanelModeContent
|
||||
mode={mode}
|
||||
insights={insights}
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
/>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{showScrollBottom && (
|
||||
<div
|
||||
onClick={onScrollBottom}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 120,
|
||||
right: 20,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: textColor,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
zIndex: 10,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.transform = 'scale(1.1)';
|
||||
event.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.transform = 'scale(1)';
|
||||
event.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)';
|
||||
}}
|
||||
>
|
||||
<DownOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default AIChatPanelConversationView;
|
||||
109
frontend/src/components/ai/aiChatPanelDerivedState.test.ts
Normal file
109
frontend/src/components/ai/aiChatPanelDerivedState.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAIChatInlineHistorySessions,
|
||||
buildAIChatInsights,
|
||||
calculateAIContextUsageChars,
|
||||
collectAIChatContextTableNames,
|
||||
inferAIChatConnectionContext,
|
||||
resolveAIChatPanelMode,
|
||||
} from './aiChatPanelDerivedState';
|
||||
|
||||
describe('aiChatPanelDerivedState', () => {
|
||||
it('falls back to tool context matches when the active context is incomplete', () => {
|
||||
const result = inferAIChatConnectionContext({
|
||||
activeConnectionId: '',
|
||||
activeDbName: '',
|
||||
messages: [
|
||||
{ id: '1', role: 'user', content: '帮我看看 orders 和 order_items 的问题', timestamp: 1 },
|
||||
],
|
||||
toolContextEntries: [
|
||||
{ connectionId: 'conn-customers', dbName: 'crm', tables: ['customers'] },
|
||||
{ connectionId: 'conn-orders', dbName: 'sales', tables: ['orders', 'order_items'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
inferredConnectionId: 'conn-orders',
|
||||
inferredDbName: 'sales',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds insight cards from recent sql logs and linked table contexts', () => {
|
||||
const insights = buildAIChatInsights({
|
||||
contextTableNames: ['sales.orders', 'sales.order_items', 'sales.customers', 'sales.payments'],
|
||||
sqlLogs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
timestamp: 1,
|
||||
sql: 'SELECT * FROM orders',
|
||||
status: 'success',
|
||||
duration: 1520,
|
||||
},
|
||||
{
|
||||
id: 'log-2',
|
||||
timestamp: 2,
|
||||
sql: 'UPDATE orders SET status = 1',
|
||||
status: 'error',
|
||||
duration: 120,
|
||||
message: 'Deadlock found',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights[0]).toMatchObject({
|
||||
tone: 'info',
|
||||
title: '已关联 4 张表',
|
||||
});
|
||||
expect(insights[0].body).toContain('sales.orders、sales.order_items、sales.customers');
|
||||
expect(insights[1]).toMatchObject({
|
||||
tone: 'warn',
|
||||
title: '最近最慢查询 1,520ms',
|
||||
});
|
||||
expect(insights[2]).toMatchObject({
|
||||
tone: 'warn',
|
||||
title: '1 条最近查询失败',
|
||||
body: 'Deadlock found',
|
||||
});
|
||||
expect(insights[3]).toMatchObject({
|
||||
tone: 'warn',
|
||||
title: '检测到 1 条写操作',
|
||||
});
|
||||
});
|
||||
|
||||
it('collects context table names, usage chars, panel mode, and inline history sessions', () => {
|
||||
expect(collectAIChatContextTableNames({
|
||||
aiContexts: {
|
||||
'conn-1:analytics': [
|
||||
{ dbName: 'analytics', tableName: 'orders', ddl: 'create table orders (...)' },
|
||||
{ dbName: 'analytics', tableName: 'events', ddl: 'create table events (...)' },
|
||||
],
|
||||
},
|
||||
activeConnectionId: 'conn-1',
|
||||
activeDbName: 'analytics',
|
||||
})).toEqual(['analytics.orders', 'analytics.events']);
|
||||
|
||||
expect(calculateAIContextUsageChars([
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'abc',
|
||||
reasoning_content: 'xy',
|
||||
tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect', arguments: '{}' } }],
|
||||
timestamp: 1,
|
||||
},
|
||||
])).toBeGreaterThan(5);
|
||||
|
||||
expect(buildAIChatInlineHistorySessions([
|
||||
{ id: '1', title: 'one', updatedAt: 1 },
|
||||
{ id: '2', title: 'two', updatedAt: 2 },
|
||||
{ id: '3', title: 'three', updatedAt: 3 },
|
||||
], 2)).toEqual([
|
||||
{ id: '1', title: 'one', updatedAt: 1 },
|
||||
{ id: '2', title: 'two', updatedAt: 2 },
|
||||
]);
|
||||
|
||||
expect(resolveAIChatPanelMode(true, 'history')).toBe('history');
|
||||
expect(resolveAIChatPanelMode(false, 'history')).toBe('chat');
|
||||
});
|
||||
});
|
||||
132
frontend/src/components/ai/aiChatPanelDerivedState.ts
Normal file
132
frontend/src/components/ai/aiChatPanelDerivedState.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { SqlLog } from '../../store';
|
||||
import type { AIChatMessage, AIContextItem } from '../../types';
|
||||
import type { AIToolContextEntry } from './aiLocalToolExecutor';
|
||||
import type { AIChatInlineHistorySession, AIChatInsightItem, AIChatPanelMode } from './AIChatPanelModeContent';
|
||||
|
||||
interface InferAIChatConnectionContextArgs {
|
||||
activeConnectionId?: string;
|
||||
activeDbName?: string;
|
||||
messages: AIChatMessage[];
|
||||
toolContextEntries: Iterable<AIToolContextEntry>;
|
||||
}
|
||||
|
||||
interface CollectAIChatContextTableNamesArgs {
|
||||
aiContexts: Record<string, AIContextItem[]>;
|
||||
activeConnectionId?: string;
|
||||
activeDbName?: string;
|
||||
}
|
||||
|
||||
interface BuildAIChatInsightsArgs {
|
||||
contextTableNames: string[];
|
||||
sqlLogs: SqlLog[];
|
||||
}
|
||||
|
||||
export const inferAIChatConnectionContext = ({
|
||||
activeConnectionId,
|
||||
activeDbName,
|
||||
messages,
|
||||
toolContextEntries,
|
||||
}: InferAIChatConnectionContextArgs) => {
|
||||
let inferredConnectionId = activeConnectionId;
|
||||
let inferredDbName = activeDbName;
|
||||
|
||||
if (!inferredConnectionId || !inferredDbName) {
|
||||
const allMsgText = messages.map((item) => item.content || '').join(' ');
|
||||
let bestMatch: { connectionId: string; dbName: string } | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const entry of toolContextEntries) {
|
||||
let score = 0;
|
||||
for (const table of entry.tables) {
|
||||
if (allMsgText.includes(table)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = { connectionId: entry.connectionId, dbName: entry.dbName };
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
if (!inferredConnectionId) {
|
||||
inferredConnectionId = bestMatch.connectionId;
|
||||
}
|
||||
if (!inferredDbName) {
|
||||
inferredDbName = bestMatch.dbName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inferredConnectionId,
|
||||
inferredDbName,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateAIContextUsageChars = (messages: AIChatMessage[]) =>
|
||||
messages.reduce(
|
||||
(sum, item) =>
|
||||
sum
|
||||
+ (item.content?.length || 0)
|
||||
+ (item.reasoning_content?.length || 0)
|
||||
+ JSON.stringify(item.tool_calls || []).length,
|
||||
0,
|
||||
);
|
||||
|
||||
export const collectAIChatContextTableNames = ({
|
||||
aiContexts,
|
||||
activeConnectionId,
|
||||
activeDbName,
|
||||
}: CollectAIChatContextTableNamesArgs) => {
|
||||
const contextKey = activeConnectionId ? `${activeConnectionId}:${activeDbName || ''}` : 'default';
|
||||
return (aiContexts[contextKey] || []).map((item) => `${item.dbName}.${item.tableName}`);
|
||||
};
|
||||
|
||||
export const buildAIChatInsights = ({
|
||||
contextTableNames,
|
||||
sqlLogs,
|
||||
}: BuildAIChatInsightsArgs): AIChatInsightItem[] => {
|
||||
const recentLogs = sqlLogs.slice(0, 24);
|
||||
const slowest = recentLogs
|
||||
.filter((log) => log.status === 'success')
|
||||
.sort((left, right) => right.duration - left.duration)[0];
|
||||
const errors = recentLogs.filter((log) => log.status === 'error');
|
||||
const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length;
|
||||
const contextCount = contextTableNames.length;
|
||||
|
||||
return [
|
||||
{
|
||||
tone: 'info',
|
||||
title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构',
|
||||
body: contextCount > 0
|
||||
? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。`
|
||||
: '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。',
|
||||
},
|
||||
{
|
||||
tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent',
|
||||
title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本',
|
||||
body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。',
|
||||
},
|
||||
{
|
||||
tone: errors.length > 0 ? 'warn' : 'info',
|
||||
title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常',
|
||||
body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'),
|
||||
},
|
||||
{
|
||||
tone: writeCount > 0 ? 'warn' : 'accent',
|
||||
title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主',
|
||||
body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const buildAIChatInlineHistorySessions = (
|
||||
sessions: AIChatInlineHistorySession[],
|
||||
limit = 8,
|
||||
) => sessions.slice(0, limit);
|
||||
|
||||
export const resolveAIChatPanelMode = (
|
||||
isV2Ui: boolean,
|
||||
activePanelMode: AIChatPanelMode,
|
||||
): AIChatPanelMode => (isV2Ui ? activePanelMode : 'chat');
|
||||
Reference in New Issue
Block a user