♻️ refactor(ai-chat): 拆分面板会话视图与派生状态逻辑

- 抽离 AIChatPanelConversationView 承载欢迎态、历史态和洞察态渲染
- 下沉连接推断、上下文统计和会话裁剪等派生逻辑到独立模块
- 补充守卫测试并验证 AI 面板定向测试、构建和真实页面切换
This commit is contained in:
Syngnat
2026-06-09 01:31:42 +08:00
parent 0a229e8156
commit 0a48f70643
6 changed files with 578 additions and 159 deletions

View File

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

View File

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

View File

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

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

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

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