diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 7345b0d..d07ea4f 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -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'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 5d54f10..d5cf0ab 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -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('盘点当前工作区'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 4a28fc2..01a3228 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -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', diff --git a/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts b/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts new file mode 100644 index 0000000..553a39b --- /dev/null +++ b/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/ai/aiExternalSqlFileInsights.ts b/frontend/src/components/ai/aiExternalSqlFileInsights.ts new file mode 100644 index 0000000..2370283 --- /dev/null +++ b/frontend/src/components/ai/aiExternalSqlFileInsights.ts @@ -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; + 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, + }; +}; diff --git a/frontend/src/components/ai/aiExternalSqlInsights.ts b/frontend/src/components/ai/aiExternalSqlInsights.ts index e3f4f90..c4c909b 100644 --- a/frontend/src/components/ai/aiExternalSqlInsights.ts +++ b/frontend/src/components/ai/aiExternalSqlInsights.ts @@ -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): boolean => { if (!keyword) { return true; @@ -19,18 +19,6 @@ const matchesKeyword = (keyword: string, fields: Array): 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, diff --git a/frontend/src/components/ai/aiExternalSqlPathUtils.ts b/frontend/src/components/ai/aiExternalSqlPathUtils.ts new file mode 100644 index 0000000..5d6ef05 --- /dev/null +++ b/frontend/src/components/ai/aiExternalSqlPathUtils.ts @@ -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]; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index c494e16..2db0c40 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -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({ diff --git a/frontend/src/components/ai/aiLocalToolRuntime.ts b/frontend/src/components/ai/aiLocalToolRuntime.ts index feab514..75f15df 100644 --- a/frontend/src/components/ai/aiLocalToolRuntime.ts +++ b/frontend/src/components/ai/aiLocalToolRuntime.ts @@ -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; getTables: (config: any, dbName: string) => Promise; getAllColumns: (config: any, dbName: string) => Promise; + readSQLFile: (filePath: string) => Promise; getColumns: (config: any, dbName: string, tableName: string) => Promise; getIndexes: (config: any, dbName: string, tableName: string) => Promise; getForeignKeys: (config: any, dbName: string, tableName: string) => Promise; @@ -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); diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index d7eca86..5249ed8 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -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; getMCPServers?: () => Promise; getMCPClientInstallStatuses?: () => Promise; + readSQLFile?: (filePath: string) => Promise; } 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: '读取当前工作区页签失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 238ee01..dfc19d7 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -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'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 7bdc2df..8d486f4 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -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', diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 0b1a5f7..6c384ff 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -44,6 +44,7 @@ const TOOL_ACTION_LABELS: Record = { 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: '盘点当前工作区页签', diff --git a/frontend/src/utils/aiBuiltinToolInfo.ts b/frontend/src/utils/aiBuiltinToolInfo.ts index 3a69a7d..02e13aa 100644 --- a/frontend/src/utils/aiBuiltinToolInfo.ts +++ b/frontend/src/utils/aiBuiltinToolInfo.ts @@ -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: "📍", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index a10651b..be3d7ce 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -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);