mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 18:41:42 +08:00
✨ feat(ai-tools): 新增 AI 历史会话探针
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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('理解样例数据');
|
||||
|
||||
@@ -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',
|
||||
|
||||
37
frontend/src/components/ai/aiChatSessionInsights.test.ts
Normal file
37
frontend/src/components/ai/aiChatSessionInsights.test.ts
Normal 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: '先检查支付回调日志',
|
||||
});
|
||||
});
|
||||
});
|
||||
136
frontend/src/components/ai/aiChatSessionInsights.ts
Normal file
136
frontend/src/components/ai/aiChatSessionInsights.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 上下文失败',
|
||||
|
||||
@@ -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('以下是当前用户的自定义补充提示词(全局)');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 执行日志',
|
||||
|
||||
@@ -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: "🧩",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user