feat(ai-tools): 新增外部 SQL 目录探针

- 新增外部 SQL 目录快照构建与本地工具执行入口
- 补充内置工具目录、系统提示和状态文案
- 覆盖 AI 面板、工具注册与探针链路测试
This commit is contained in:
Syngnat
2026-06-09 03:26:04 +08:00
parent f7648413ed
commit 4162a6491b
14 changed files with 322 additions and 2 deletions

View File

@@ -45,8 +45,10 @@ describe('AIChatPanel message render isolation', () => {
expect(systemContextSource).toContain('inspect_active_tab 读取当前活动页签上下文');
expect(systemContextSource).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(systemContextSource).toContain('inspect_current_connection');
expect(systemContextSource).toContain('inspect_external_sql_directories');
expect(source).toContain('tabs: useStore.getState().tabs');
expect(source).toContain('activeTabId: useStore.getState().activeTabId');
expect(source).toContain('externalSQLDirectories: useStore.getState().externalSQLDirectories');
expect(source).toContain('toolContextMap: toolContextMapRef.current');
expect(source).toContain('buildToolResultMessage');
});

View File

@@ -461,6 +461,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
sqlLogs: useStore.getState().sqlLogs,
savedQueries: useStore.getState().savedQueries,
sqlSnippets: useStore.getState().sqlSnippets,
externalSQLDirectories: useStore.getState().externalSQLDirectories,
skills,
userPromptSettings,
dynamicModels,

View File

@@ -46,6 +46,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_connection_capabilities');
expect(markup).toContain('盘点本地连接资产');
expect(markup).toContain('inspect_saved_connections');
expect(markup).toContain('盘点外部 SQL 目录');
expect(markup).toContain('inspect_external_sql_directories');
expect(markup).toContain('读取当前页签');
expect(markup).toContain('inspect_active_tab');
expect(markup).toContain('盘点当前工作区');

View File

@@ -87,6 +87,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_saved_connections → inspect_current_connection / get_databases',
description: '适合先按关键词或类型筛出本地保存的数据源,再挑目标连接继续看当前状态或库表结构。',
},
{
title: '盘点外部 SQL 目录',
steps: 'inspect_external_sql_directories → inspect_workspace_tabs / inspect_active_tab',
description: '适合先确认本地配置了哪些外部 SQL 目录、目录绑定到哪个连接/库,以及当前打开的 SQL 文件来自哪里,再继续分析脚本内容。',
},
{
title: '读取当前页签',
steps: 'inspect_active_tab → get_columns / get_indexes / execute_sql',

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types';
import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights';
const connections: SavedConnection[] = [
{
id: 'conn-1',
name: '本地开发库',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
},
},
];
describe('aiExternalSqlInsights', () => {
it('filters configured external sql directories and reports matching open file tabs', () => {
const externalSQLDirectories: ExternalSQLDirectory[] = [
{
id: 'dir-1',
name: '报表脚本',
path: 'D:/sql/reports',
connectionId: 'conn-1',
dbName: 'crm',
createdAt: 2,
},
{
id: 'dir-2',
name: '运维脚本',
path: 'D:/sql/ops',
createdAt: 1,
},
];
const tabs: TabData[] = [
{
id: 'tab-1',
title: '日报.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
filePath: 'D:/sql/reports/daily.sql',
query: 'select 1',
},
{
id: 'tab-2',
title: '用户.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
filePath: 'D:/sql/reports/users/detail.sql',
query: 'select 2',
},
];
const snapshot = buildExternalSQLDirectoriesSnapshot({
externalSQLDirectories,
connections,
tabs,
keyword: '报表',
});
expect(snapshot.totalMatched).toBe(1);
expect(snapshot.returnedDirectories).toBe(1);
expect(snapshot.totalOpenExternalSqlTabs).toBe(2);
expect(snapshot.boundConnectionCount).toBe(1);
expect(snapshot.directories[0]).toMatchObject({
id: 'dir-1',
connectionName: '本地开发库',
connectionType: 'mysql',
dbName: 'crm',
openFileTabCount: 2,
hasBoundConnection: true,
});
expect(snapshot.directories[0].openFileTitles[0].title).toBe('日报.sql');
});
});

View File

@@ -0,0 +1,117 @@
import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types';
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 normalizePath = (input: unknown): string =>
String(input || '').trim().replace(/\\/g, '/').replace(/\/+$/u, '');
const matchesKeyword = (keyword: string, fields: Array<string | undefined>): boolean => {
if (!keyword) {
return true;
}
return fields.some((field) => String(field || '').toLowerCase().includes(keyword));
};
const belongsToDirectory = (filePath: string, directoryPath: string): boolean => {
if (!filePath || !directoryPath) {
return false;
}
const normalizedFilePath = normalizePath(filePath).toLowerCase();
const normalizedDirectoryPath = normalizePath(directoryPath).toLowerCase();
if (!normalizedFilePath || !normalizedDirectoryPath) {
return false;
}
return normalizedFilePath === normalizedDirectoryPath || normalizedFilePath.startsWith(`${normalizedDirectoryPath}/`);
};
export const buildExternalSQLDirectoriesSnapshot = (params: {
externalSQLDirectories?: ExternalSQLDirectory[];
connections: SavedConnection[];
tabs?: TabData[];
keyword?: unknown;
connectionId?: unknown;
dbName?: unknown;
limit?: unknown;
}) => {
const {
externalSQLDirectories = [],
connections,
tabs = [],
keyword,
connectionId,
dbName,
limit,
} = params;
const safeKeyword = normalizeKeyword(keyword);
const safeConnectionId = String(connectionId || '').trim();
const safeDbName = String(dbName || '').trim();
const safeLimit = normalizeLimit(limit, 20, 100);
const externalSqlTabs = tabs.filter((tab) => String(tab.filePath || '').trim());
const filteredDirectories = [...externalSQLDirectories]
.sort((left, right) => Number(right.createdAt || 0) - Number(left.createdAt || 0))
.filter((directory) => {
if (safeConnectionId && String(directory.connectionId || '').trim() !== safeConnectionId) {
return false;
}
if (safeDbName && String(directory.dbName || '').trim() !== safeDbName) {
return false;
}
const connection = connections.find((item) => item.id === directory.connectionId);
return matchesKeyword(safeKeyword, [
directory.id,
directory.name,
directory.path,
directory.connectionId,
directory.dbName,
connection?.name,
connection?.config?.type,
]);
});
const visibleDirectories = filteredDirectories.slice(0, safeLimit).map((directory) => {
const connection = connections.find((item) => item.id === directory.connectionId);
const matchingTabs = externalSqlTabs.filter((tab) => belongsToDirectory(String(tab.filePath || ''), directory.path));
return {
id: directory.id,
name: directory.name,
path: directory.path,
connectionId: directory.connectionId || '',
connectionName: connection?.name || '',
connectionType: connection?.config?.type || '',
dbName: directory.dbName || '',
createdAt: Number(directory.createdAt || 0),
hasBoundConnection: Boolean(String(directory.connectionId || '').trim()),
openFileTabCount: matchingTabs.length,
openFileTitles: matchingTabs.slice(0, 5).map((tab) => ({
tabId: tab.id,
title: tab.title,
filePath: tab.filePath || '',
dbName: tab.dbName || '',
})),
};
});
return {
keyword: safeKeyword,
connectionId: safeConnectionId,
dbName: safeDbName,
limit: safeLimit,
totalMatched: filteredDirectories.length,
returnedDirectories: visibleDirectories.length,
truncated: filteredDirectories.length > visibleDirectories.length,
totalConfiguredDirectories: externalSQLDirectories.length,
totalOpenExternalSqlTabs: externalSqlTabs.length,
boundConnectionCount: filteredDirectories.filter((item) => String(item.connectionId || '').trim()).length,
directories: visibleDirectories,
};
};

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { AIMCPToolDescriptor, AIToolCall, SavedConnection } from '../../types';
import type { AIMCPToolDescriptor, AIToolCall, ExternalSQLDirectory, SavedConnection } from '../../types';
import { buildToolResultMessage, executeLocalAIToolCall } from './aiLocalToolExecutor';
const buildConnection = (): SavedConnection => ({
@@ -605,6 +605,57 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).not.toContain('分析仓库');
});
it('returns configured external sql directories so the model can locate local script assets', async () => {
const externalSQLDirectories: ExternalSQLDirectory[] = [
{
id: 'dir-1',
name: '报表脚本',
path: 'D:/sql/reports',
connectionId: 'conn-1',
dbName: 'crm',
createdAt: 2,
},
{
id: 'dir-2',
name: '运维脚本',
path: 'D:/sql/ops',
createdAt: 1,
},
];
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_external_sql_directories', {
keyword: '报表',
}),
connections: [buildConnection()],
tabs: [
{
id: 'tab-1',
title: '日报.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
filePath: 'D:/sql/reports/daily.sql',
query: 'select 1',
},
],
mcpTools: [],
toolContextMap: new Map(),
externalSQLDirectories,
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"totalMatched":1');
expect(result.content).toContain('"name":"报表脚本"');
expect(result.content).toContain('"connectionName":"主库"');
expect(result.content).toContain('"openFileTabCount":1');
expect(result.content).toContain('日报.sql');
expect(result.content).not.toContain('运维脚本');
});
it('blocks execute_sql when the AI safety check rejects the statement', async () => {
const query = vi.fn();
const result = await executeLocalAIToolCall({

View File

@@ -9,6 +9,7 @@ import type {
SavedConnection,
SavedQuery,
SqlSnippet,
ExternalSQLDirectory,
TabData,
} from '../../types';
import { executeDatabaseToolCall } from './aiDatabaseToolExecutor';
@@ -36,6 +37,7 @@ export interface ExecuteLocalAIToolCallOptions {
sqlLogs?: SqlLog[];
savedQueries?: SavedQuery[];
sqlSnippets?: SqlSnippet[];
externalSQLDirectories?: ExternalSQLDirectory[];
skills?: AISkillConfig[];
userPromptSettings?: AIUserPromptSettings;
dynamicModels?: string[];
@@ -66,6 +68,7 @@ export async function executeLocalAIToolCall({
sqlLogs = [],
savedQueries = [],
sqlSnippets = [],
externalSQLDirectories = [],
skills = [],
userPromptSettings,
dynamicModels = [],
@@ -92,6 +95,7 @@ export async function executeLocalAIToolCall({
sqlLogs,
savedQueries,
sqlSnippets,
externalSQLDirectories,
skills,
userPromptSettings,
dynamicModels,

View File

@@ -8,6 +8,7 @@ import type {
AISafetyLevel,
AISkillConfig,
AIUserPromptSettings,
ExternalSQLDirectory,
SavedConnection,
SavedQuery,
SqlSnippet,
@@ -30,6 +31,7 @@ import {
buildSqlSnippetsSnapshot,
} from './aiSavedSqlInsights';
import { buildSavedConnectionsSnapshot } from './aiSavedConnectionInsights';
import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights';
import {
buildActiveTabSnapshot,
buildRecentSqlLogsSnapshot,
@@ -64,6 +66,7 @@ interface ExecuteSnapshotInspectionToolCallOptions {
sqlLogs?: SqlLog[];
savedQueries?: SavedQuery[];
sqlSnippets?: SqlSnippet[];
externalSQLDirectories?: ExternalSQLDirectory[];
skills?: AISkillConfig[];
userPromptSettings?: AIUserPromptSettings;
dynamicModels?: string[];
@@ -95,6 +98,7 @@ export async function executeSnapshotInspectionToolCall(
sqlLogs = [],
savedQueries = [],
sqlSnippets = [],
externalSQLDirectories = [],
skills = [],
userPromptSettings,
dynamicModels = [],
@@ -220,6 +224,19 @@ export async function executeSnapshotInspectionToolCall(
})),
success: true,
};
case 'inspect_external_sql_directories':
return {
content: JSON.stringify(buildExternalSQLDirectoriesSnapshot({
externalSQLDirectories,
connections,
tabs,
keyword: args.keyword,
connectionId: args.connectionId,
dbName: args.dbName,
limit: args.limit,
})),
success: true,
};
case 'inspect_active_tab':
return {
content: JSON.stringify(buildActiveTabSnapshot({
@@ -310,6 +327,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_current_connection: '读取当前连接失败',
inspect_connection_capabilities: '读取当前连接能力矩阵失败',
inspect_saved_connections: '读取本地连接清单失败',
inspect_external_sql_directories: '读取外部 SQL 目录失败',
inspect_ai_sessions: '读取本地 AI 会话清单失败',
inspect_active_tab: '读取当前活动页签失败',
inspect_workspace_tabs: '读取当前工作区页签失败',

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_ai_sessions', '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_external_sql_directories', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'get_columns'],
skills,
userPromptSettings,
});
@@ -85,6 +85,7 @@ describe('buildAISystemContextMessages', () => {
expect(joined).toContain('inspect_current_connection');
expect(joined).toContain('inspect_connection_capabilities');
expect(joined).toContain('inspect_saved_connections');
expect(joined).toContain('inspect_external_sql_directories');
expect(joined).toContain('inspect_saved_queries');
expect(joined).toContain('inspect_ai_sessions');
expect(joined).toContain('inspect_sql_snippets');

View File

@@ -423,6 +423,12 @@ SELECT * FROM users WHERE status = 1;
content: '如果用户提到“本地存了哪些连接”“帮我找 mysql / postgres / redis 连接”“哪条连接配了 SSH/代理”,优先调用 inspect_saved_connections 读取真实本地连接清单,再决定继续查看哪条连接。',
});
}
if (availableToolNames.includes('inspect_external_sql_directories')) {
systemMessages.push({
role: 'system',
content: '如果用户提到“外部 SQL 目录”“目录里的脚本”“某个 SQL 文件放在哪个目录”“当前打开的 SQL 文件来自哪里”,优先调用 inspect_external_sql_directories 读取真实外部 SQL 目录资产,再决定继续读取活动页签还是定位具体脚本。',
});
}
if (availableToolNames.includes('inspect_saved_queries')) {
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_external_sql_directories: '盘点外部 SQL 目录',
inspect_ai_sessions: '盘点本地 AI 历史会话',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',

View File

@@ -495,6 +495,31 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_external_sql_directories",
icon: "🗂️",
desc: "查看本地外部 SQL 目录资产",
detail:
"可按关键词、连接或数据库过滤,返回本地配置的外部 SQL 目录、目录路径、绑定连接/数据库,以及当前是否已经打开这些目录里的 SQL 文件。适合用户提到“外部 SQL 目录”“某个脚本在哪个目录”“现在打开的 SQL 文件来自哪个外部目录”时,先读真实资产。",
params: "keyword?, connectionId?, dbName?, limit?",
tool: {
type: "function",
function: {
name: "inspect_external_sql_directories",
description:
"读取本地配置的外部 SQL 目录清单,可按关键词、连接和数据库过滤,并返回目录路径、绑定连接/数据库,以及当前打开的外部 SQL 文件页签摘要。适用于用户提到外部 SQL 目录、某个 SQL 文件放在哪、当前打开的脚本来自哪个目录时,先读取真实本地资产再回答。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按目录名、路径、连接名或数据库名做关键词筛选" },
connectionId: { type: "string", description: "可选,只看绑定到某个连接的外部 SQL 目录" },
dbName: { type: "string", description: "可选,只看绑定到某个数据库的外部 SQL 目录" },
limit: { type: "number", description: "可选,最多返回多少条目录,默认 20最大 100" },
},
},
},
},
},
{
name: "inspect_active_tab",
icon: "📍",

View File

@@ -66,6 +66,13 @@ describe('aiToolRegistry', () => {
expect(info?.tool.function.description).toContain('本地已保存连接清单');
});
it('registers the external-sql-directory inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_external_sql_directories');
expect(info).toBeTruthy();
expect(info?.desc).toContain('外部 SQL 目录');
expect(info?.tool.function.description).toContain('当前打开的外部 SQL 文件页签');
});
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');
@@ -104,6 +111,7 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true);
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_external_sql_directories')).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);