diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index d5cf0ab..d830b92 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -56,6 +56,8 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_workspace_tabs'); expect(markup).toContain('回看最近执行记录'); expect(markup).toContain('inspect_recent_sql_logs'); + expect(markup).toContain('总结最近 SQL 活动'); + expect(markup).toContain('inspect_recent_sql_activity'); expect(markup).toContain('复用历史 SQL'); expect(markup).toContain('inspect_saved_queries'); expect(markup).toContain('回看 AI 历史对话'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 01a3228..1644ef5 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -112,6 +112,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_recent_sql_logs → get_columns / get_indexes / execute_sql', description: '适合追查刚刚执行失败的 SQL、慢查询耗时,或基于真实执行历史继续让 AI 给解释和优化建议。', }, + { + title: '总结最近 SQL 活动', + steps: 'inspect_recent_sql_activity → inspect_recent_sql_logs → inspect_current_connection', + description: '适合先看最近到底以读还是写为主、有没有 DDL 或删除、哪个库最近报错最多,再决定继续下钻哪条日志或哪个连接。', + }, { title: '复用历史 SQL', steps: 'inspect_saved_queries → get_columns / execute_sql', diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 2db0c40..ab766a5 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -988,6 +988,70 @@ describe('aiLocalToolExecutor', () => { expect(result.content).not.toContain('SELECT * FROM users LIMIT 10'); }); + it('returns a recent sql activity summary so the model can quickly spot writes, ddl, and repeated failures', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_recent_sql_activity', { + limit: 3, + activityKind: 'write', + dbName: 'crm', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + sqlLogs: [ + { + id: 'log-1', + timestamp: 4, + sql: 'DELETE FROM users WHERE id = 9', + status: 'error', + duration: 120, + message: 'permission denied', + dbName: 'crm', + }, + { + id: 'log-2', + timestamp: 3, + sql: 'UPDATE orders SET status = \'paid\' WHERE id = 1', + status: 'error', + duration: 95, + message: 'row lock timeout', + dbName: 'crm', + }, + { + id: 'log-3', + timestamp: 2, + sql: 'ALTER TABLE orders ADD COLUMN note varchar(32)', + status: 'success', + duration: 160, + dbName: 'crm', + }, + { + id: 'log-4', + timestamp: 1, + sql: 'SELECT * FROM users LIMIT 10', + status: 'success', + duration: 18, + dbName: 'crm', + affectedRows: 10, + }, + ], + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"activityKind":"write"'); + expect(result.content).toContain('"totalMatched":2'); + expect(result.content).toContain('"writeCount":2'); + expect(result.content).toContain('"statementTypeBreakdown":{"delete":1,"update":1}'); + expect(result.content).toContain('permission denied'); + expect(result.content).toContain('row lock timeout'); + expect(result.content).not.toContain('ALTER TABLE orders'); + expect(result.content).not.toContain('SELECT * FROM users LIMIT 10'); + }); + it('returns local saved queries so the model can reuse historical sql scripts', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_saved_queries', { diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index 5249ed8..9a54bec 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -35,8 +35,11 @@ import { buildExternalSQLFileSnapshot } from './aiExternalSqlFileInsights'; import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights'; import { findBestMatchingExternalSQLDirectory } from './aiExternalSqlPathUtils'; import { - buildActiveTabSnapshot, + buildRecentSqlActivitySnapshot, buildRecentSqlLogsSnapshot, +} from './aiSqlLogInsights'; +import { + buildActiveTabSnapshot, buildWorkspaceTabsSnapshot, } from './aiWorkspaceInsights'; @@ -328,6 +331,18 @@ export async function executeSnapshotInspectionToolCall( })), success: true, }; + case 'inspect_recent_sql_activity': + return { + content: JSON.stringify(buildRecentSqlActivitySnapshot({ + sqlLogs, + limit: args.limit, + status: args.status, + keyword: args.keyword, + dbName: args.dbName, + activityKind: args.activityKind, + })), + success: true, + }; case 'inspect_saved_queries': return { content: JSON.stringify(buildSavedQueriesSnapshot({ @@ -372,6 +387,7 @@ export async function executeSnapshotInspectionToolCall( inspect_workspace_tabs: '读取当前工作区页签失败', inspect_ai_context: '读取当前 AI 上下文失败', inspect_recent_sql_logs: '获取最近 SQL 日志失败', + inspect_recent_sql_activity: '汇总最近 SQL 活动失败', inspect_saved_queries: '读取已保存查询失败', inspect_sql_snippets: '读取 SQL 片段失败', }[toolName] || '读取本地探针快照失败'; diff --git a/frontend/src/components/ai/aiSqlLogInsights.test.ts b/frontend/src/components/ai/aiSqlLogInsights.test.ts new file mode 100644 index 0000000..40b6fbe --- /dev/null +++ b/frontend/src/components/ai/aiSqlLogInsights.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRecentSqlActivitySnapshot, buildRecentSqlLogsSnapshot } from './aiSqlLogInsights'; + +describe('aiSqlLogInsights', () => { + it('keeps recent sql logs as structured previews with inferred statement metadata', () => { + const snapshot = buildRecentSqlLogsSnapshot({ + status: 'all', + limit: 2, + sqlLogs: [ + { + id: 'log-1', + timestamp: 2, + sql: '/* note */ SELECT * FROM users', + status: 'success', + duration: 10, + dbName: 'crm', + affectedRows: 2, + }, + { + id: 'log-2', + timestamp: 1, + sql: 'DELETE FROM users WHERE id = 9', + status: 'error', + duration: 50, + dbName: 'crm', + message: 'permission denied', + }, + ], + }); + + expect(snapshot.totalMatched).toBe(2); + expect(snapshot.logs[0]).toMatchObject({ + statementType: 'select', + activityKind: 'read', + }); + expect(snapshot.logs[1]).toMatchObject({ + statementType: 'delete', + activityKind: 'write', + }); + }); + + it('builds a recent sql activity summary with filters, breakdowns, slowest statements, and top errors', () => { + const snapshot = buildRecentSqlActivitySnapshot({ + status: 'all', + activityKind: 'all', + keyword: 'orders', + dbName: 'crm', + limit: 5, + sqlLogs: [ + { + id: 'log-1', + timestamp: 5, + sql: 'UPDATE orders SET status = \'paid\' WHERE id = 1', + status: 'error', + duration: 90, + dbName: 'crm', + message: 'row lock timeout', + }, + { + id: 'log-2', + timestamp: 4, + sql: 'ALTER TABLE orders ADD COLUMN note varchar(32)', + status: 'success', + duration: 120, + dbName: 'crm', + }, + { + id: 'log-3', + timestamp: 3, + sql: 'WITH recent AS (SELECT * FROM orders) SELECT * FROM recent', + status: 'success', + duration: 18, + dbName: 'crm', + }, + { + id: 'log-4', + timestamp: 2, + sql: 'SET search_path TO analytics', + status: 'success', + duration: 5, + dbName: 'crm', + }, + { + id: 'log-5', + timestamp: 1, + sql: 'SELECT * FROM users', + status: 'success', + duration: 12, + dbName: 'crm', + }, + ], + }); + + expect(snapshot.totalMatched).toBe(3); + expect(snapshot.writeCount).toBe(1); + expect(snapshot.ddlCount).toBe(1); + expect(snapshot.readCount).toBe(1); + expect(snapshot.statementTypeBreakdown).toEqual({ + alter: 1, + update: 1, + with: 1, + }); + expect(snapshot.dbBreakdown).toEqual({ + crm: 3, + }); + expect(snapshot.topErrorMessages).toEqual([ + { message: 'row lock timeout', count: 1 }, + ]); + expect(snapshot.slowestStatements[0]).toMatchObject({ + statementType: 'alter', + activityKind: 'ddl', + }); + expect(snapshot.recentMutations).toHaveLength(2); + expect(snapshot.recentErrors[0]).toMatchObject({ + statementType: 'update', + activityKind: 'write', + }); + }); +}); diff --git a/frontend/src/components/ai/aiSqlLogInsights.ts b/frontend/src/components/ai/aiSqlLogInsights.ts new file mode 100644 index 0000000..8322866 --- /dev/null +++ b/frontend/src/components/ai/aiSqlLogInsights.ts @@ -0,0 +1,316 @@ +import type { SqlLog } from '../../store'; + +type SqlLogStatusFilter = 'all' | 'success' | 'error'; +type SqlActivityKind = 'read' | 'write' | 'ddl' | 'transaction' | 'session' | 'other'; +type SqlActivityKindFilter = 'all' | SqlActivityKind; +type SqlStatementType = + | 'select' + | 'insert' + | 'update' + | 'delete' + | 'replace' + | 'merge' + | 'create' + | 'alter' + | 'drop' + | 'truncate' + | 'rename' + | 'show' + | 'describe' + | 'explain' + | 'use' + | 'set' + | 'begin' + | 'commit' + | 'rollback' + | 'with' + | 'other'; + +const MAX_SQL_LOG_LIMIT = 100; +const DEFAULT_SQL_LOG_LIMIT = 20; +const DEFAULT_SQL_ACTIVITY_LIMIT = 30; + +const normalizeSqlLogLimit = (input: unknown, fallback = DEFAULT_SQL_LOG_LIMIT): number => { + const value = Math.floor(Number(input) || fallback); + if (value < 1) return 1; + if (value > MAX_SQL_LOG_LIMIT) return MAX_SQL_LOG_LIMIT; + return value; +}; + +const normalizeSqlLogStatus = (input: unknown): SqlLogStatusFilter => { + const value = String(input || 'all').trim().toLowerCase(); + if (value === 'success' || value === 'error') { + return value; + } + return 'all'; +}; + +const normalizeSqlActivityKind = (input: unknown): SqlActivityKindFilter => { + const value = String(input || 'all').trim().toLowerCase(); + if ( + value === 'read' + || value === 'write' + || value === 'ddl' + || value === 'transaction' + || value === 'session' + || value === 'other' + ) { + return value; + } + return 'all'; +}; + +const stripLeadingSqlComments = (input: string): string => { + let text = String(input || ''); + while (true) { + const trimmedStart = text.trimStart(); + if (!trimmedStart) { + return ''; + } + if (trimmedStart.startsWith('--') || trimmedStart.startsWith('#')) { + const lineEnd = trimmedStart.indexOf('\n'); + text = lineEnd >= 0 ? trimmedStart.slice(lineEnd + 1) : ''; + continue; + } + if (trimmedStart.startsWith('/*')) { + const blockEnd = trimmedStart.indexOf('*/'); + if (blockEnd < 0) { + return ''; + } + text = trimmedStart.slice(blockEnd + 2); + continue; + } + return trimmedStart; + } +}; + +const resolveWithStatementType = (normalizedSql: string): SqlStatementType => { + const writePatterns: Array<{ keyword: SqlStatementType; regex: RegExp }> = [ + { keyword: 'insert', regex: /\binsert\s+into\b/u }, + { keyword: 'update', regex: /\bupdate\b/u }, + { keyword: 'delete', regex: /\bdelete\s+from\b/u }, + { keyword: 'replace', regex: /\breplace\s+into\b/u }, + { keyword: 'merge', regex: /\bmerge\s+into\b/u }, + ]; + const ddlPatterns: Array<{ keyword: SqlStatementType; regex: RegExp }> = [ + { keyword: 'create', regex: /\bcreate\s+(table|view|index|schema|database)\b/u }, + { keyword: 'alter', regex: /\balter\s+(table|view|index|schema|database)\b/u }, + { keyword: 'drop', regex: /\bdrop\s+(table|view|index|schema|database)\b/u }, + { keyword: 'truncate', regex: /\btruncate\s+table\b/u }, + { keyword: 'rename', regex: /\brename\s+(table|to)\b/u }, + ]; + + const writeMatch = writePatterns.find((item) => item.regex.test(normalizedSql)); + if (writeMatch) { + return writeMatch.keyword; + } + const ddlMatch = ddlPatterns.find((item) => item.regex.test(normalizedSql)); + if (ddlMatch) { + return ddlMatch.keyword; + } + return /\bselect\b/u.test(normalizedSql) ? 'with' : 'other'; +}; + +const classifySqlStatement = (sql: string): { statementType: SqlStatementType; activityKind: SqlActivityKind } => { + const normalizedSql = stripLeadingSqlComments(sql).toLowerCase(); + if (!normalizedSql) { + return { statementType: 'other', activityKind: 'other' }; + } + + const firstKeyword = normalizedSql.match(/^[a-z]+/u)?.[0] || 'other'; + const statementType: SqlStatementType = (() => { + switch (firstKeyword) { + case 'select': + case 'insert': + case 'update': + case 'delete': + case 'replace': + case 'merge': + case 'create': + case 'alter': + case 'drop': + case 'truncate': + case 'rename': + case 'show': + case 'use': + case 'set': + case 'begin': + case 'commit': + case 'rollback': + return firstKeyword; + case 'desc': + case 'describe': + return 'describe'; + case 'explain': + return 'explain'; + case 'with': + return resolveWithStatementType(normalizedSql); + default: + return 'other'; + } + })(); + + switch (statementType) { + case 'select': + case 'show': + case 'describe': + case 'explain': + case 'with': + return { statementType, activityKind: 'read' }; + case 'insert': + case 'update': + case 'delete': + case 'replace': + case 'merge': + return { statementType, activityKind: 'write' }; + case 'create': + case 'alter': + case 'drop': + case 'truncate': + case 'rename': + return { statementType, activityKind: 'ddl' }; + case 'begin': + case 'commit': + case 'rollback': + return { statementType, activityKind: 'transaction' }; + case 'use': + case 'set': + return { statementType, activityKind: 'session' }; + default: + return { statementType: 'other', activityKind: 'other' }; + } +}; + +const buildCountBreakdown = (items: string[]): Record => + Object.fromEntries( + Array.from( + items.reduce((map, item) => { + const key = String(item || 'unknown').trim() || 'unknown'; + map.set(key, (map.get(key) || 0) + 1); + return map; + }, new Map()).entries(), + ).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])), + ); + +const buildLogPreview = (log: SqlLog) => { + const classification = classifySqlStatement(log.sql); + return { + id: log.id, + timestamp: log.timestamp, + status: log.status, + duration: log.duration, + dbName: log.dbName || '', + affectedRows: typeof log.affectedRows === 'number' ? log.affectedRows : null, + statementType: classification.statementType, + activityKind: classification.activityKind, + sql: log.sql, + message: log.message || '', + }; +}; + +export const buildRecentSqlLogsSnapshot = (params: { + sqlLogs?: SqlLog[]; + limit?: unknown; + status?: unknown; +}) => { + const { sqlLogs = [], limit, status } = params; + const safeStatus = normalizeSqlLogStatus(status); + const safeLimit = normalizeSqlLogLimit(limit); + const filteredLogs = sqlLogs.filter((log) => safeStatus === 'all' || log.status === safeStatus); + + return { + status: safeStatus, + limit: safeLimit, + totalMatched: filteredLogs.length, + successCount: filteredLogs.filter((log) => log.status === 'success').length, + errorCount: filteredLogs.filter((log) => log.status === 'error').length, + logs: filteredLogs.slice(0, safeLimit).map(buildLogPreview), + }; +}; + +export const buildRecentSqlActivitySnapshot = (params: { + sqlLogs?: SqlLog[]; + limit?: unknown; + status?: unknown; + keyword?: unknown; + dbName?: unknown; + activityKind?: unknown; +}) => { + const { sqlLogs = [], limit, status, keyword, dbName, activityKind } = params; + const safeLimit = normalizeSqlLogLimit(limit, DEFAULT_SQL_ACTIVITY_LIMIT); + const safeStatus = normalizeSqlLogStatus(status); + const safeKeyword = String(keyword || '').trim().toLowerCase(); + const safeDbName = String(dbName || '').trim().toLowerCase(); + const safeActivityKind = normalizeSqlActivityKind(activityKind); + + const enrichedLogs = sqlLogs.map(buildLogPreview); + const filteredLogs = enrichedLogs.filter((log) => { + if (safeStatus !== 'all' && log.status !== safeStatus) { + return false; + } + if (safeActivityKind !== 'all' && log.activityKind !== safeActivityKind) { + return false; + } + if (safeDbName && !String(log.dbName || '').toLowerCase().includes(safeDbName)) { + return false; + } + if (safeKeyword) { + const haystack = [ + log.dbName, + log.statementType, + log.activityKind, + log.sql, + log.message, + ].join('\n').toLowerCase(); + if (!haystack.includes(safeKeyword)) { + return false; + } + } + return true; + }); + + const statementTypeBreakdown = buildCountBreakdown(filteredLogs.map((log) => log.statementType)); + const dbBreakdown = buildCountBreakdown(filteredLogs.map((log) => log.dbName || '(未指定数据库)')); + const errorMessageBreakdown = buildCountBreakdown( + filteredLogs + .filter((log) => log.status === 'error' && String(log.message || '').trim()) + .map((log) => String(log.message || '').trim()), + ); + + const recentExamples = filteredLogs.slice(0, safeLimit); + const recentMutations = filteredLogs + .filter((log) => log.activityKind === 'write' || log.activityKind === 'ddl') + .slice(0, 5); + const recentErrors = filteredLogs + .filter((log) => log.status === 'error') + .slice(0, 5); + const slowestStatements = [...filteredLogs] + .sort((left, right) => right.duration - left.duration || right.timestamp - left.timestamp) + .slice(0, 5); + + return { + status: safeStatus, + activityKind: safeActivityKind, + keyword: safeKeyword, + dbName: safeDbName, + limit: safeLimit, + totalMatched: filteredLogs.length, + successCount: filteredLogs.filter((log) => log.status === 'success').length, + errorCount: filteredLogs.filter((log) => log.status === 'error').length, + readCount: filteredLogs.filter((log) => log.activityKind === 'read').length, + writeCount: filteredLogs.filter((log) => log.activityKind === 'write').length, + ddlCount: filteredLogs.filter((log) => log.activityKind === 'ddl').length, + transactionCount: filteredLogs.filter((log) => log.activityKind === 'transaction').length, + sessionCount: filteredLogs.filter((log) => log.activityKind === 'session').length, + otherCount: filteredLogs.filter((log) => log.activityKind === 'other').length, + statementTypeBreakdown, + dbBreakdown, + topErrorMessages: Object.entries(errorMessageBreakdown) + .slice(0, 5) + .map(([message, count]) => ({ message, count })), + recentMutations, + recentErrors, + slowestStatements, + recentExamples, + }; +}; diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index dfc19d7..a01a619 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_external_sql_file', '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_recent_sql_activity', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'get_columns'], skills, userPromptSettings, }); @@ -87,6 +87,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_saved_connections'); expect(joined).toContain('inspect_external_sql_directories'); expect(joined).toContain('inspect_external_sql_file'); + expect(joined).toContain('inspect_recent_sql_activity'); 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 8d486f4..78c4f87 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -435,6 +435,12 @@ SELECT * FROM users WHERE status = 1; content: '如果用户已经给出了某个外部 SQL 文件路径,或明确提到“帮我看看这个目录里的 report.sql / job.sql 在写什么”,优先调用 inspect_external_sql_file 读取真实文件内容;如果这个文件已经在编辑器中打开,再结合 inspect_active_tab 看当前草稿。', }); } + if (availableToolNames.includes('inspect_recent_sql_activity')) { + systemMessages.push({ + role: 'system', + content: '如果用户提到“最近都执行了什么”“是不是刚删过数据”“最近主要在查还是在改”“哪个库最近报错最多”,优先调用 inspect_recent_sql_activity 先读最近 SQL 活动总结,再决定是否继续下钻 inspect_recent_sql_logs 看具体语句。', + }); + } if (availableToolNames.includes('inspect_saved_queries')) { systemMessages.push({ role: 'system', diff --git a/frontend/src/components/ai/aiWorkspaceInsights.ts b/frontend/src/components/ai/aiWorkspaceInsights.ts index e50485b..a2ff5a5 100644 --- a/frontend/src/components/ai/aiWorkspaceInsights.ts +++ b/frontend/src/components/ai/aiWorkspaceInsights.ts @@ -1,4 +1,3 @@ -import type { SqlLog } from '../../store'; import type { SavedConnection, TabData } from '../../types'; const ACTIVE_TAB_CONTENT_LIMIT = 12000; @@ -11,21 +10,6 @@ const normalizeWorkspaceTabLimit = (input: unknown): number => { return value; }; -const normalizeRecentSqlLogLimit = (input: unknown): number => { - const value = Math.floor(Number(input) || 20); - if (value < 1) return 1; - if (value > 100) return 100; - return value; -}; - -const normalizeSqlLogStatus = (input: unknown): 'all' | 'success' | 'error' => { - const value = String(input || 'all').trim().toLowerCase(); - if (value === 'success' || value === 'error') { - return value; - } - return 'all'; -}; - const resolveContentKind = (tab: TabData, includeContent: boolean, trimmedContent: string): 'sql' | 'command' | 'text' | 'none' => { if (!includeContent || !trimmedContent) { return 'none'; @@ -160,33 +144,3 @@ export const buildWorkspaceTabsSnapshot = (params: { })), }; }; - -export const buildRecentSqlLogsSnapshot = (params: { - sqlLogs?: SqlLog[]; - limit?: unknown; - status?: unknown; -}) => { - const { sqlLogs = [], limit, status } = params; - const safeStatus = normalizeSqlLogStatus(status); - const safeLimit = normalizeRecentSqlLogLimit(limit); - const filteredLogs = sqlLogs.filter((log) => safeStatus === 'all' || log.status === safeStatus); - const visibleLogs = filteredLogs.slice(0, safeLimit).map((log) => ({ - id: log.id, - timestamp: log.timestamp, - status: log.status, - duration: log.duration, - dbName: log.dbName || '', - affectedRows: typeof log.affectedRows === 'number' ? log.affectedRows : null, - sql: log.sql, - message: log.message || '', - })); - - return { - status: safeStatus, - limit: safeLimit, - totalMatched: filteredLogs.length, - successCount: filteredLogs.filter((log) => log.status === 'success').length, - errorCount: filteredLogs.filter((log) => log.status === 'error').length, - logs: visibleLogs, - }; -}; diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 6c384ff..499132a 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -49,6 +49,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_active_tab: '读取当前活动页签', inspect_workspace_tabs: '盘点当前工作区页签', inspect_recent_sql_logs: '回看最近 SQL 执行日志', + inspect_recent_sql_activity: '总结最近 SQL 活动', inspect_saved_queries: '检索本地已保存查询', inspect_sql_snippets: '读取 SQL 片段模板', preview_table_rows: '预览真实样例数据', diff --git a/frontend/src/utils/aiBuiltinToolInfo.ts b/frontend/src/utils/aiBuiltinToolInfo.ts index 02e13aa..d4b08a5 100644 --- a/frontend/src/utils/aiBuiltinToolInfo.ts +++ b/frontend/src/utils/aiBuiltinToolInfo.ts @@ -616,6 +616,40 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_recent_sql_activity", + icon: "📊", + desc: "总结最近 SQL 活动分布", + detail: + "可按 status、activityKind、dbName 和 keyword 过滤,返回最近 SQL 活动的结构化总结,包括读写/DDL 比例、语句类型分布、数据库分布、最近报错、最近写操作和最慢语句。适合用户提到“最近都执行了什么”“是不是刚删过数据”“哪个库最近报错最多”“最近主要在跑查询还是写入”时先读真实执行画像。", + params: "limit?, status?(all|success|error), activityKind?(all|read|write|ddl|transaction|session|other), dbName?, keyword?", + tool: { + type: "function", + function: { + name: "inspect_recent_sql_activity", + description: + "汇总最近 SQL 活动的结构化画像,可按执行状态、活动类型、数据库名和关键词过滤。适用于排查最近主要在执行哪些读写操作、某个库近期错误是否集中、是否发生过删除或 DDL、以及让 AI 基于真实执行现场先做全局判断。", + parameters: { + type: "object", + properties: { + limit: { type: "number", description: "可选,最近活动样例最多返回多少条,默认 30,最大 100" }, + status: { + type: "string", + description: "可选,按执行状态过滤,支持 all、success、error,默认 all", + enum: ["all", "success", "error"], + }, + activityKind: { + type: "string", + description: "可选,按活动类型过滤,支持 all、read、write、ddl、transaction、session、other,默认 all", + enum: ["all", "read", "write", "ddl", "transaction", "session", "other"], + }, + dbName: { type: "string", description: "可选,只看数据库名里包含该关键词的日志" }, + keyword: { type: "string", description: "可选,按 SQL 文本、报错信息、语句类型或数据库名做关键词筛选" }, + }, + }, + }, + }, + }, { name: "inspect_saved_queries", icon: "💾", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index be3d7ce..195c6d6 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -80,11 +80,14 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('目录中的具体 SQL 脚本'); }); - it('registers the saved-query and sql-snippet inspectors as builtin tools', () => { + it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => { + const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity'); 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(recentActivityTool?.desc).toContain('最近 SQL 活动'); + expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动'); expect(savedQueryTool?.desc).toContain('已保存的 SQL 查询'); expect(savedQueryTool?.tool.function.description).toContain('历史查询'); expect(aiSessionsTool?.desc).toContain('AI 历史会话'); @@ -120,6 +123,7 @@ describe('aiToolRegistry', () => { 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_recent_sql_activity')).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);