feat(ai-tools): 新增外部 SQL 文件探针

This commit is contained in:
Syngnat
2026-06-09 03:51:04 +08:00
parent 4162a6491b
commit b4f46aeecd
15 changed files with 393 additions and 18 deletions

View File

@@ -46,6 +46,7 @@ describe('AIChatPanel message render isolation', () => {
expect(systemContextSource).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(systemContextSource).toContain('inspect_current_connection');
expect(systemContextSource).toContain('inspect_external_sql_directories');
expect(systemContextSource).toContain('inspect_external_sql_file');
expect(source).toContain('tabs: useStore.getState().tabs');
expect(source).toContain('activeTabId: useStore.getState().activeTabId');
expect(source).toContain('externalSQLDirectories: useStore.getState().externalSQLDirectories');

View File

@@ -48,6 +48,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_saved_connections');
expect(markup).toContain('盘点外部 SQL 目录');
expect(markup).toContain('inspect_external_sql_directories');
expect(markup).toContain('读取外部 SQL 文件');
expect(markup).toContain('inspect_external_sql_file');
expect(markup).toContain('读取当前页签');
expect(markup).toContain('inspect_active_tab');
expect(markup).toContain('盘点当前工作区');

View File

@@ -92,6 +92,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_external_sql_directories → inspect_workspace_tabs / inspect_active_tab',
description: '适合先确认本地配置了哪些外部 SQL 目录、目录绑定到哪个连接/库,以及当前打开的 SQL 文件来自哪里,再继续分析脚本内容。',
},
{
title: '读取外部 SQL 文件',
steps: 'inspect_external_sql_directories → inspect_external_sql_file → inspect_active_tab',
description: '适合先定位具体脚本路径,再直接读取目录中的 SQL 文件内容;如果这个文件已经在编辑器里打开,再继续结合当前页签草稿一起分析。',
},
{
title: '读取当前页签',
steps: 'inspect_active_tab → get_columns / get_indexes / execute_sql',

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types';
import { buildExternalSQLFileSnapshot } from './aiExternalSqlFileInsights';
const connections: SavedConnection[] = [
{
id: 'conn-1',
name: '本地开发库',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
},
},
];
describe('aiExternalSqlFileInsights', () => {
it('builds a file snapshot with directory metadata, open tab context, and truncated content preview', () => {
const externalSQLDirectories: ExternalSQLDirectory[] = [
{
id: 'dir-1',
name: '报表脚本',
path: 'D:/sql/reports',
connectionId: 'conn-1',
dbName: 'crm',
createdAt: 1,
},
];
const tabs: TabData[] = [
{
id: 'tab-1',
title: 'daily.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
filePath: 'D:/sql/reports/daily.sql',
query: 'select 1',
},
];
const snapshot = buildExternalSQLFileSnapshot({
filePath: 'D:/sql/reports/daily.sql',
previewCharLimit: 12,
readResult: {
content: 'SELECT * FROM orders WHERE status = \'paid\';',
filePath: 'D:/sql/reports/daily.sql',
name: 'daily.sql',
},
externalSQLDirectories,
connections,
tabs,
});
expect(snapshot.hasMatchedDirectory).toBe(true);
expect(snapshot.directory).toMatchObject({
name: '报表脚本',
connectionName: '本地开发库',
connectionType: 'mysql',
dbName: 'crm',
});
expect(snapshot.hasOpenTab).toBe(true);
expect(snapshot.openTabCount).toBe(1);
expect(snapshot.fileName).toBe('daily.sql');
expect(snapshot.contentPreview).toBe('SELECT * FRO');
expect(snapshot.truncated).toBe(true);
});
});

View File

@@ -0,0 +1,116 @@
import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types';
import {
findBestMatchingExternalSQLDirectory,
normalizeExternalSQLPath,
} from './aiExternalSqlPathUtils';
const normalizePreviewCharLimit = (input: unknown): number => {
const value = Math.floor(Number(input) || 12000);
if (value < 1) return 1;
if (value > 40000) return 40000;
return value;
};
const normalizeFileReadPayload = (
readResult: unknown,
): {
content: string;
filePath: string;
name: string;
isLargeFile: boolean;
fileSize: number;
fileSizeMB: string;
} => {
if (typeof readResult === 'string') {
return {
content: readResult,
filePath: '',
name: '',
isLargeFile: false,
fileSize: 0,
fileSizeMB: '',
};
}
if (!readResult || typeof readResult !== 'object') {
return {
content: '',
filePath: '',
name: '',
isLargeFile: false,
fileSize: 0,
fileSizeMB: '',
};
}
const payload = readResult as Record<string, unknown>;
return {
content: typeof payload.content === 'string' ? payload.content : '',
filePath: String(payload.filePath || '').trim(),
name: String(payload.name || '').trim(),
isLargeFile: payload.isLargeFile === true,
fileSize: Number(payload.fileSize || 0),
fileSizeMB: String(payload.fileSizeMB || '').trim(),
};
};
export const buildExternalSQLFileSnapshot = (params: {
filePath: unknown;
previewCharLimit?: unknown;
readResult?: unknown;
externalSQLDirectories?: ExternalSQLDirectory[];
connections: SavedConnection[];
tabs?: TabData[];
}) => {
const {
filePath,
previewCharLimit,
readResult,
externalSQLDirectories = [],
connections,
tabs = [],
} = params;
const requestedFilePath = normalizeExternalSQLPath(filePath);
const payload = normalizeFileReadPayload(readResult);
const resolvedFilePath = normalizeExternalSQLPath(payload.filePath || requestedFilePath);
const matchedDirectory = findBestMatchingExternalSQLDirectory(resolvedFilePath, externalSQLDirectories);
const matchedConnection = connections.find((item) => item.id === matchedDirectory?.connectionId);
const matchingTabs = tabs.filter(
(tab) => normalizeExternalSQLPath(tab.filePath || '').toLowerCase() === resolvedFilePath.toLowerCase(),
);
const previewLimit = normalizePreviewCharLimit(previewCharLimit);
const previewContent = payload.content.slice(0, previewLimit);
const inferredName = payload.name || resolvedFilePath.split('/').filter(Boolean).pop() || '';
return {
requestedFilePath,
resolvedFilePath,
fileName: inferredName,
previewCharLimit: previewLimit,
hasMatchedDirectory: Boolean(matchedDirectory),
directory: matchedDirectory ? {
id: matchedDirectory.id,
name: matchedDirectory.name,
path: matchedDirectory.path,
connectionId: matchedDirectory.connectionId || '',
connectionName: matchedConnection?.name || '',
connectionType: matchedConnection?.config?.type || '',
dbName: matchedDirectory.dbName || '',
} : null,
hasOpenTab: matchingTabs.length > 0,
openTabCount: matchingTabs.length,
openTabs: matchingTabs.slice(0, 5).map((tab) => ({
tabId: tab.id,
title: tab.title,
dbName: tab.dbName || '',
connectionId: tab.connectionId || '',
isActiveFileTab: true,
})),
isLargeFile: payload.isLargeFile,
fileSize: payload.fileSize,
fileSizeMB: payload.fileSizeMB,
hasContentPreview: previewContent.length > 0,
truncated: payload.content.length > previewContent.length,
contentPreview: previewContent,
contentLength: payload.content.length,
};
};

View File

@@ -1,4 +1,7 @@
import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types';
import {
isExternalSQLPathInsideDirectory,
} from './aiExternalSqlPathUtils';
const normalizeLimit = (input: unknown, fallback: number, max: number): number => {
const value = Math.floor(Number(input) || fallback);
@@ -9,9 +12,6 @@ const normalizeLimit = (input: unknown, fallback: number, max: number): number =
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;
@@ -19,18 +19,6 @@ const matchesKeyword = (keyword: string, fields: Array<string | undefined>): boo
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[];
@@ -79,7 +67,7 @@ export const buildExternalSQLDirectoriesSnapshot = (params: {
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));
const matchingTabs = externalSqlTabs.filter((tab) => isExternalSQLPathInsideDirectory(String(tab.filePath || ''), directory.path));
return {
id: directory.id,

View File

@@ -0,0 +1,29 @@
import type { ExternalSQLDirectory } from '../../types';
export const normalizeExternalSQLPath = (input: unknown): string =>
String(input || '').trim().replace(/\\/g, '/').replace(/\/+$/u, '');
export const isExternalSQLPathInsideDirectory = (filePath: string, directoryPath: string): boolean => {
if (!filePath || !directoryPath) {
return false;
}
const normalizedFilePath = normalizeExternalSQLPath(filePath).toLowerCase();
const normalizedDirectoryPath = normalizeExternalSQLPath(directoryPath).toLowerCase();
if (!normalizedFilePath || !normalizedDirectoryPath) {
return false;
}
return normalizedFilePath === normalizedDirectoryPath || normalizedFilePath.startsWith(`${normalizedDirectoryPath}/`);
};
export const findBestMatchingExternalSQLDirectory = (
filePath: string,
directories: ExternalSQLDirectory[],
): ExternalSQLDirectory | undefined => {
const normalizedFilePath = normalizeExternalSQLPath(filePath).toLowerCase();
if (!normalizedFilePath) {
return undefined;
}
return [...directories]
.filter((directory) => isExternalSQLPathInsideDirectory(normalizedFilePath, directory.path))
.sort((left, right) => normalizeExternalSQLPath(right.path).length - normalizeExternalSQLPath(left.path).length)[0];
};

View File

@@ -656,6 +656,90 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).not.toContain('运维脚本');
});
it('reads a configured external sql file so the model can inspect script content directly', async () => {
const readSQLFile = vi.fn().mockResolvedValue({
success: true,
data: {
content: 'SELECT * FROM orders WHERE status = \'paid\';',
filePath: 'D:/sql/reports/daily.sql',
name: 'daily.sql',
},
});
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_external_sql_file', {
filePath: 'D:/sql/reports/daily.sql',
previewCharLimit: 18,
}),
connections: [buildConnection()],
tabs: [
{
id: 'tab-1',
title: 'daily.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
filePath: 'D:/sql/reports/daily.sql',
query: 'select 1',
},
],
mcpTools: [],
toolContextMap: new Map(),
externalSQLDirectories: [
{
id: 'dir-1',
name: '报表脚本',
path: 'D:/sql/reports',
connectionId: 'conn-1',
dbName: 'crm',
createdAt: 1,
},
],
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
readSQLFile,
},
});
expect(result.success).toBe(true);
expect(readSQLFile).toHaveBeenCalledWith('D:/sql/reports/daily.sql');
expect(result.content).toContain('"fileName":"daily.sql"');
expect(result.content).toContain('"connectionName":"主库"');
expect(result.content).toContain('"hasOpenTab":true');
expect(result.content).toContain('SELECT * FROM orde');
});
it('blocks external sql file reads outside configured directories', async () => {
const readSQLFile = vi.fn();
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_external_sql_file', {
filePath: 'D:/private/secret.sql',
}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
externalSQLDirectories: [
{
id: 'dir-1',
name: '报表脚本',
path: 'D:/sql/reports',
connectionId: 'conn-1',
dbName: 'crm',
createdAt: 1,
},
],
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
readSQLFile,
},
});
expect(result.success).toBe(false);
expect(result.content).toContain('目标文件不在已配置的外部 SQL 目录中');
expect(readSQLFile).not.toHaveBeenCalled();
});
it('blocks execute_sql when the AI safety check rejects the statement', async () => {
const query = vi.fn();
const result = await executeLocalAIToolCall({

View File

@@ -1,4 +1,4 @@
import { DBGetAllColumns, DBGetDatabases, DBGetTables } from '../../../wailsjs/go/app/App';
import { DBGetAllColumns, DBGetDatabases, DBGetTables, ReadSQLFile } from '../../../wailsjs/go/app/App';
import type { AISnapshotInspectionRuntime } from './aiSnapshotInspectionToolExecutor';
@@ -12,6 +12,7 @@ export interface AILocalToolRuntime extends AISnapshotInspectionRuntime {
getDatabases: (config: any) => Promise<any>;
getTables: (config: any, dbName: string) => Promise<any>;
getAllColumns: (config: any, dbName: string) => Promise<any>;
readSQLFile: (filePath: string) => Promise<any>;
getColumns: (config: any, dbName: string, tableName: string) => Promise<any>;
getIndexes: (config: any, dbName: string, tableName: string) => Promise<any>;
getForeignKeys: (config: any, dbName: string, tableName: string) => Promise<any>;
@@ -28,6 +29,7 @@ export const buildDefaultLocalToolRuntime = (): AILocalToolRuntime => ({
getDatabases: DBGetDatabases,
getTables: DBGetTables,
getAllColumns: DBGetAllColumns,
readSQLFile: ReadSQLFile,
getColumns: async (config, dbName, tableName) => {
const mod = await import('../../../wailsjs/go/app/App');
return mod.DBGetColumns(config, dbName, tableName);

View File

@@ -31,7 +31,9 @@ import {
buildSqlSnippetsSnapshot,
} from './aiSavedSqlInsights';
import { buildSavedConnectionsSnapshot } from './aiSavedConnectionInsights';
import { buildExternalSQLFileSnapshot } from './aiExternalSqlFileInsights';
import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights';
import { findBestMatchingExternalSQLDirectory } from './aiExternalSqlPathUtils';
import {
buildActiveTabSnapshot,
buildRecentSqlLogsSnapshot,
@@ -49,6 +51,7 @@ export interface AISnapshotInspectionRuntime {
getAIRuntimeState?: () => Promise<AISnapshotInspectionRuntimeState | undefined>;
getMCPServers?: () => Promise<AIMCPServerConfig[] | undefined>;
getMCPClientInstallStatuses?: () => Promise<AIMCPClientInstallStatus[] | undefined>;
readSQLFile?: (filePath: string) => Promise<any>;
}
interface ExecuteSnapshotInspectionToolCallOptions {
@@ -237,6 +240,41 @@ export async function executeSnapshotInspectionToolCall(
})),
success: true,
};
case 'inspect_external_sql_file': {
const requestedFilePath = String(args.filePath || '').trim();
if (!requestedFilePath) {
return {
content: '读取外部 SQL 文件失败: filePath 不能为空',
success: false,
};
}
if (!findBestMatchingExternalSQLDirectory(requestedFilePath, externalSQLDirectories)) {
return {
content: '读取外部 SQL 文件失败: 目标文件不在已配置的外部 SQL 目录中',
success: false,
};
}
const readResult = typeof runtime?.readSQLFile === 'function'
? await runtime.readSQLFile(requestedFilePath)
: { success: false, message: '当前环境暂不支持读取本地 SQL 文件' };
if (!readResult?.success) {
return {
content: `读取外部 SQL 文件失败: ${readResult?.message || '未知错误'}`,
success: false,
};
}
return {
content: JSON.stringify(buildExternalSQLFileSnapshot({
filePath: requestedFilePath,
previewCharLimit: args.previewCharLimit,
readResult: readResult?.data,
externalSQLDirectories,
connections,
tabs,
})),
success: true,
};
}
case 'inspect_active_tab':
return {
content: JSON.stringify(buildActiveTabSnapshot({
@@ -328,6 +366,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_connection_capabilities: '读取当前连接能力矩阵失败',
inspect_saved_connections: '读取本地连接清单失败',
inspect_external_sql_directories: '读取外部 SQL 目录失败',
inspect_external_sql_file: '读取外部 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_external_sql_directories', '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_external_sql_file', '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_external_sql_directories');
expect(joined).toContain('inspect_external_sql_file');
expect(joined).toContain('inspect_saved_queries');
expect(joined).toContain('inspect_ai_sessions');
expect(joined).toContain('inspect_sql_snippets');

View File

@@ -429,6 +429,12 @@ SELECT * FROM users WHERE status = 1;
content: '如果用户提到“外部 SQL 目录”“目录里的脚本”“某个 SQL 文件放在哪个目录”“当前打开的 SQL 文件来自哪里”,优先调用 inspect_external_sql_directories 读取真实外部 SQL 目录资产,再决定继续读取活动页签还是定位具体脚本。',
});
}
if (availableToolNames.includes('inspect_external_sql_file')) {
systemMessages.push({
role: 'system',
content: '如果用户已经给出了某个外部 SQL 文件路径,或明确提到“帮我看看这个目录里的 report.sql / job.sql 在写什么”,优先调用 inspect_external_sql_file 读取真实文件内容;如果这个文件已经在编辑器中打开,再结合 inspect_active_tab 看当前草稿。',
});
}
if (availableToolNames.includes('inspect_saved_queries')) {
systemMessages.push({
role: 'system',

View File

@@ -44,6 +44,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_connection_capabilities: '读取当前连接能力矩阵',
inspect_saved_connections: '盘点本地已保存连接',
inspect_external_sql_directories: '盘点外部 SQL 目录',
inspect_external_sql_file: '读取外部 SQL 文件',
inspect_ai_sessions: '盘点本地 AI 历史会话',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',

View File

@@ -520,6 +520,30 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_external_sql_file",
icon: "📄",
desc: "读取外部 SQL 文件内容",
detail:
"传入具体 filePath读取已配置外部 SQL 目录中的 SQL 文件内容,并返回所属目录、绑定连接/数据库、是否已有打开页签,以及截断后的正文预览。适合用户提到“看一下这个目录里的某个脚本”“帮我解释 report.sql 在写什么”时,先读取真实文件内容再分析。",
params: "filePath, previewCharLimit?",
tool: {
type: "function",
function: {
name: "inspect_external_sql_file",
description:
"读取指定外部 SQL 文件的内容预览,仅用于已配置外部 SQL 目录中的 SQL 文件。返回文件路径、所属目录、绑定连接/数据库、是否已在工作区打开,以及截断后的正文内容。适用于用户提到某个目录中的具体 SQL 脚本、想让 AI 直接解释脚本逻辑、或想确认某个外部 SQL 文件内容时,先读真实文件再回答。",
parameters: {
type: "object",
properties: {
filePath: { type: "string", description: "必填,要读取的 SQL 文件绝对路径,通常先通过 inspect_external_sql_directories 找到" },
previewCharLimit: { type: "number", description: "可选,正文预览最多返回多少字符,默认 12000最大 40000" },
},
required: ["filePath"],
},
},
},
},
{
name: "inspect_active_tab",
icon: "📍",

View File

@@ -73,6 +73,13 @@ describe('aiToolRegistry', () => {
expect(info?.tool.function.description).toContain('当前打开的外部 SQL 文件页签');
});
it('registers the external-sql-file inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_external_sql_file');
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');
@@ -112,6 +119,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_external_sql_directories')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).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);