feat(ai-tools): 新增 AI 历史会话探针

This commit is contained in:
Syngnat
2026-06-09 02:11:35 +08:00
parent b723b16671
commit 747cabe447
13 changed files with 287 additions and 1 deletions

View File

@@ -698,6 +698,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
connections: currentConnections,
activeContext: useStore.getState().activeContext,
aiContexts: useStore.getState().aiContexts,
aiChatHistory: useStore.getState().aiChatHistory,
aiChatSessions: useStore.getState().aiChatSessions,
activeSessionId: sid,
tabs: useStore.getState().tabs,
activeTabId: useStore.getState().activeTabId,
mcpTools,

View File

@@ -54,6 +54,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_recent_sql_logs');
expect(markup).toContain('复用历史 SQL');
expect(markup).toContain('inspect_saved_queries');
expect(markup).toContain('回看 AI 历史对话');
expect(markup).toContain('inspect_ai_sessions');
expect(markup).toContain('查找模板片段');
expect(markup).toContain('inspect_sql_snippets');
expect(markup).toContain('理解样例数据');

View File

@@ -107,6 +107,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_saved_queries → get_columns / execute_sql',
description: '适合先找本地保存过的查询脚本,再核对字段和只读验证,避免把之前写过的 SQL 重新手打一遍。',
},
{
title: '回看 AI 历史对话',
steps: 'inspect_ai_sessions → inspect_active_tab / inspect_saved_queries',
description: '适合先定位之前聊过的 AI 会话、首条问题和最近回复,再继续复用当前页签或历史 SQL 上下文。',
},
{
title: '查找模板片段',
steps: 'inspect_sql_snippets',

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { buildAIChatSessionsSnapshot } from './aiChatSessionInsights';
describe('aiChatSessionInsights', () => {
it('filters and summarizes ai sessions with previews from local history', () => {
const snapshot = buildAIChatSessionsSnapshot({
aiChatSessions: [
{ id: 'session-1', title: '支付异常排查', updatedAt: 200 },
{ id: 'session-2', title: '库存核对', updatedAt: 100 },
],
aiChatHistory: {
'session-1': [
{ id: 'msg-1', role: 'user', content: '帮我看支付超时', timestamp: 101 },
{ id: 'msg-2', role: 'assistant', content: '先检查支付回调日志', timestamp: 102 },
],
'session-2': [
{ id: 'msg-3', role: 'user', content: '库存差异怎么查', timestamp: 103 },
],
},
activeSessionId: 'session-2',
keyword: '支付',
limit: 5,
});
expect(snapshot.totalSessions).toBe(2);
expect(snapshot.totalMatched).toBe(1);
expect(snapshot.sessions[0]).toMatchObject({
id: 'session-1',
title: '支付异常排查',
isActive: false,
messageCount: 2,
firstUserPromptPreview: '帮我看支付超时',
latestMessagePreview: '先检查支付回调日志',
});
});
});

View File

@@ -0,0 +1,136 @@
import type { AIChatMessage } from '../../types';
interface AIChatSessionMeta {
id: string;
title: string;
updatedAt: number;
}
const AI_CHAT_SESSION_PREVIEW_LIMIT = 240;
const normalizeLimit = (input: unknown, fallback: number, max: number): number => {
const value = Math.floor(Number(input) || fallback);
if (value < 1) return 1;
if (value > max) return max;
return value;
};
const normalizeKeyword = (input: unknown): string => String(input || '').trim().toLowerCase();
const buildPreviewText = (messages: AIChatMessage[]) => {
const firstUserMessage = messages.find((message) => message.role === 'user' && String(message.content || '').trim());
const latestMeaningfulMessage = [...messages]
.reverse()
.find((message) => String(message.content || '').trim() || String(message.reasoning_content || '').trim());
const firstUserPrompt = String(firstUserMessage?.content || '').trim();
const latestMessageText = String(
latestMeaningfulMessage?.content || latestMeaningfulMessage?.reasoning_content || '',
).trim();
return {
firstUserPrompt,
latestMessageText,
};
};
const matchesKeyword = (keyword: string, fields: Array<string | undefined>) => {
if (!keyword) {
return true;
}
return fields.some((field) => String(field || '').toLowerCase().includes(keyword));
};
export const buildAIChatSessionsSnapshot = (params: {
aiChatSessions?: AIChatSessionMeta[];
aiChatHistory?: Record<string, AIChatMessage[]>;
activeSessionId?: string | null;
keyword?: unknown;
limit?: unknown;
includePreview?: unknown;
}) => {
const {
aiChatSessions = [],
aiChatHistory = {},
activeSessionId = null,
keyword,
limit,
includePreview = true,
} = params;
const safeKeyword = normalizeKeyword(keyword);
const safeLimit = normalizeLimit(limit, 10, 50);
const shouldIncludePreview = includePreview !== false;
const sessionMetaMap = new Map(aiChatSessions.map((session) => [session.id, session]));
const sessionIds = new Set<string>([
...aiChatSessions.map((session) => session.id),
...Object.keys(aiChatHistory || {}),
]);
const allSessions = [...sessionIds].map((sessionId) => {
const meta = sessionMetaMap.get(sessionId);
const messages = [...(aiChatHistory[sessionId] || [])].sort((left, right) => left.timestamp - right.timestamp);
const updatedAt = Number(
meta?.updatedAt || messages[messages.length - 1]?.timestamp || messages[0]?.timestamp || 0,
);
const { firstUserPrompt, latestMessageText } = buildPreviewText(messages);
const firstUserPromptPreview = shouldIncludePreview
? firstUserPrompt.slice(0, AI_CHAT_SESSION_PREVIEW_LIMIT)
: '';
const latestMessagePreview = shouldIncludePreview
? latestMessageText.slice(0, AI_CHAT_SESSION_PREVIEW_LIMIT)
: '';
return {
id: sessionId,
title: String(meta?.title || '').trim() || '未命名会话',
updatedAt,
isActive: sessionId === activeSessionId,
messageCount: messages.length,
userMessageCount: messages.filter((message) => message.role === 'user').length,
assistantMessageCount: messages.filter((message) => message.role === 'assistant').length,
toolMessageCount: messages.filter((message) => message.role === 'tool').length,
hasToolMessages: messages.some((message) => message.role === 'tool'),
hasErrorMessages: messages.some((message) => String(message.content || '').includes('❌')),
lastMessageRole: messages[messages.length - 1]?.role || '',
lastMessageAt: Number(messages[messages.length - 1]?.timestamp || 0),
firstUserPromptPreview,
firstUserPromptTruncated: shouldIncludePreview && firstUserPrompt.length > firstUserPromptPreview.length,
latestMessagePreview,
latestMessageTruncated: shouldIncludePreview && latestMessageText.length > latestMessagePreview.length,
};
});
const filteredSessions = allSessions
.filter((session) =>
matchesKeyword(safeKeyword, [
session.id,
session.title,
session.firstUserPromptPreview,
session.latestMessagePreview,
]))
.sort((left, right) => {
if (left.isActive && !right.isActive) {
return -1;
}
if (!left.isActive && right.isActive) {
return 1;
}
return right.updatedAt - left.updatedAt;
});
const visibleSessions = filteredSessions.slice(0, safeLimit);
return {
activeSessionId: activeSessionId || '',
keyword: safeKeyword,
includePreview: shouldIncludePreview,
limit: safeLimit,
totalSessions: allSessions.length,
totalMatched: filteredSessions.length,
returnedSessions: visibleSessions.length,
truncated: filteredSessions.length > visibleSessions.length,
sessions: visibleSessions,
};
};

View File

@@ -893,6 +893,43 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).toContain('status = \'paid\'');
});
it('returns local ai chat sessions so the model can locate previous conversations by title or preview', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_ai_sessions', {
keyword: '支付',
limit: 5,
}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
aiChatSessions: [
{ id: 'session-1', title: '支付异常排查', updatedAt: 200 },
{ id: 'session-2', title: '用户列表', updatedAt: 100 },
],
aiChatHistory: {
'session-1': [
{ id: 'msg-1', role: 'user', content: '帮我排查支付超时', timestamp: 101 },
{ id: 'msg-2', role: 'assistant', content: '先看最近错误日志', timestamp: 102 },
],
'session-2': [
{ id: 'msg-3', role: 'user', content: '列出最近注册用户', timestamp: 103 },
],
},
activeSessionId: 'session-2',
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"totalMatched":1');
expect(result.content).toContain('支付异常排查');
expect(result.content).toContain('帮我排查支付超时');
expect(result.content).toContain('先看最近错误日志');
expect(result.content).not.toContain('列出最近注册用户');
});
it('returns sql snippets so the model can inspect local query templates', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_sql_snippets', {

View File

@@ -26,6 +26,9 @@ export interface ExecuteLocalAIToolCallOptions {
connections: SavedConnection[];
activeContext?: { connectionId: string; dbName: string } | null;
aiContexts?: Record<string, AIContextItem[]>;
aiChatHistory?: Record<string, AIChatMessage[]>;
aiChatSessions?: Array<{ id: string; title: string; updatedAt: number }>;
activeSessionId?: string | null;
tabs?: TabData[];
activeTabId?: string | null;
mcpTools: AIMCPToolDescriptor[];
@@ -53,6 +56,9 @@ export async function executeLocalAIToolCall({
connections,
activeContext = null,
aiContexts = {},
aiChatHistory = {},
aiChatSessions = [],
activeSessionId = null,
tabs = [],
activeTabId = null,
mcpTools,
@@ -76,6 +82,9 @@ export async function executeLocalAIToolCall({
args,
activeContext,
aiContexts,
aiChatHistory,
aiChatSessions,
activeSessionId,
connections,
tabs,
activeTabId,

View File

@@ -3,6 +3,7 @@ import type {
AIMCPClientInstallStatus,
AIMCPServerConfig,
AIMCPToolDescriptor,
AIChatMessage,
AIProviderConfig,
AISafetyLevel,
AISkillConfig,
@@ -15,6 +16,7 @@ import type {
import type { SqlLog } from '../../store';
import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry';
import { buildAIContextSnapshot } from './aiContextInsights';
import { buildAIChatSessionsSnapshot } from './aiChatSessionInsights';
import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights';
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
import { buildMCPSetupSnapshot } from './aiMCPInsights';
@@ -52,6 +54,9 @@ interface ExecuteSnapshotInspectionToolCallOptions {
args: Record<string, any>;
activeContext?: { connectionId: string; dbName: string } | null;
aiContexts?: Record<string, AIContextItem[]>;
aiChatHistory?: Record<string, AIChatMessage[]>;
aiChatSessions?: Array<{ id: string; title: string; updatedAt: number }>;
activeSessionId?: string | null;
connections: SavedConnection[];
tabs?: TabData[];
activeTabId?: string | null;
@@ -80,6 +85,9 @@ export async function executeSnapshotInspectionToolCall(
args,
activeContext = null,
aiContexts = {},
aiChatHistory = {},
aiChatSessions = [],
activeSessionId = null,
connections,
tabs = [],
activeTabId = null,
@@ -244,6 +252,18 @@ export async function executeSnapshotInspectionToolCall(
})),
success: true,
};
case 'inspect_ai_sessions':
return {
content: JSON.stringify(buildAIChatSessionsSnapshot({
aiChatSessions,
aiChatHistory,
activeSessionId,
keyword: args.keyword,
limit: args.limit,
includePreview: args.includePreview !== false,
})),
success: true,
};
case 'inspect_recent_sql_logs':
return {
content: JSON.stringify(buildRecentSqlLogsSnapshot({
@@ -290,6 +310,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_current_connection: '读取当前连接失败',
inspect_connection_capabilities: '读取当前连接能力矩阵失败',
inspect_saved_connections: '读取本地连接清单失败',
inspect_ai_sessions: '读取本地 AI 会话清单失败',
inspect_active_tab: '读取当前活动页签失败',
inspect_workspace_tabs: '读取当前工作区页签失败',
inspect_ai_context: '读取当前 AI 上下文失败',

View File

@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
connections: [connections[0]],
tabs: [],
activeTabId: null,
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'],
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'get_columns'],
skills,
userPromptSettings,
});
@@ -86,6 +86,7 @@ describe('buildAISystemContextMessages', () => {
expect(joined).toContain('inspect_connection_capabilities');
expect(joined).toContain('inspect_saved_connections');
expect(joined).toContain('inspect_saved_queries');
expect(joined).toContain('inspect_ai_sessions');
expect(joined).toContain('inspect_sql_snippets');
expect(joined).toContain('当前连接');
expect(joined).toContain('以下是当前用户的自定义补充提示词(全局)');

View File

@@ -429,6 +429,12 @@ SELECT * FROM users WHERE status = 1;
content: '如果用户提到“保存过的查询”“历史 SQL”“之前写过的语句”“帮我找以前那条脚本”优先调用 inspect_saved_queries 读取本地已保存查询,再决定是否继续核对字段或复用 SQL。',
});
}
if (availableToolNames.includes('inspect_ai_sessions')) {
systemMessages.push({
role: 'system',
content: '如果用户提到“之前那条 AI 对话”“上次聊过的记录”“最近哪个会话说过这个问题”,优先调用 inspect_ai_sessions 读取本地 AI 会话清单和预览,再决定继续查看当前页签还是复用历史 SQL。',
});
}
if (availableToolNames.includes('inspect_sql_snippets')) {
systemMessages.push({
role: 'system',

View File

@@ -43,6 +43,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_current_connection: '读取当前连接摘要',
inspect_connection_capabilities: '读取当前连接能力矩阵',
inspect_saved_connections: '盘点本地已保存连接',
inspect_ai_sessions: '盘点本地 AI 历史会话',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',
inspect_recent_sql_logs: '回看最近 SQL 执行日志',

View File

@@ -593,6 +593,30 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_ai_sessions",
icon: "🗂️",
desc: "查看本地 AI 历史会话清单",
detail:
"可按关键词过滤,返回本地 AI 会话标题、更新时间、消息数量、是否是当前会话,以及首条用户提问和最近一条消息预览。适合用户提到“之前那条 AI 对话”“帮我找上次聊过的记录”“最近哪个会话讲过这个问题”时先读真实会话资产。",
params: "keyword?, limit?, includePreview?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_ai_sessions",
description:
"读取本地 AI 历史会话清单,可按关键词过滤,并返回会话标题、更新时间、消息数量、是否是当前活动会话,以及首条用户问题和最近消息预览。适用于用户提到之前的 AI 对话、上次聊过的记录、最近哪个会话讲过某个问题时,先读取真实会话清单再继续定位。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按会话标题、会话 ID、首条用户问题或最近消息内容做关键词筛选" },
limit: { type: "number", description: "可选,最多返回多少条会话,默认 10最大 50" },
includePreview: { type: "boolean", description: "可选,是否附带首条用户问题和最近消息预览,默认 true" },
},
},
},
},
},
{
name: "inspect_sql_snippets",
icon: "🧩",

View File

@@ -68,10 +68,13 @@ describe('aiToolRegistry', () => {
it('registers the saved-query and sql-snippet inspectors as builtin tools', () => {
const savedQueryTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_queries');
const aiSessionsTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_sessions');
const snippetTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_snippets');
expect(savedQueryTool?.desc).toContain('已保存的 SQL 查询');
expect(savedQueryTool?.tool.function.description).toContain('历史查询');
expect(aiSessionsTool?.desc).toContain('AI 历史会话');
expect(aiSessionsTool?.tool.function.description).toContain('之前的 AI 对话');
expect(snippetTool?.desc).toContain('SQL 片段模板');
expect(snippetTool?.tool.function.description).toContain('片段模板');
});
@@ -102,6 +105,7 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_connection_capabilities')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_saved_connections')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_sessions')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_snippets')).toBe(true);
expect(tools.some((item) => item.function.name === 'custom_probe')).toBe(true);
});