mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 17:31:32 +08:00
✨ feat(ai-tools): 新增最近 SQL 活动总结探针
- 新增 inspect_recent_sql_activity 总结最近 SQL 活动的读写与报错分布 - 抽离 SQL 日志洞察模块并复用 recent logs 快照逻辑 - 补齐工具目录、系统提示、状态文案与测试覆盖
This commit is contained in:
@@ -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 历史对话');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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] || '读取本地探针快照失败';
|
||||
|
||||
120
frontend/src/components/ai/aiSqlLogInsights.test.ts
Normal file
120
frontend/src/components/ai/aiSqlLogInsights.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
316
frontend/src/components/ai/aiSqlLogInsights.ts
Normal file
316
frontend/src/components/ai/aiSqlLogInsights.ts
Normal file
@@ -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<string, number> =>
|
||||
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<string, number>()).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,
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
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: '预览真实样例数据',
|
||||
|
||||
@@ -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: "💾",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user