mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-tools): 新增 SQL 风险预检并优化视图定位
- 新增 inspect_sql_risk 内置工具,识别多语句、写入、DDL、无 WHERE 和安全策略风险 - 在 AI 设置内置工具目录和系统提示中补充 SQL 风险预检链路 - 修复同名视图定位时优先当前数据库 schema 的匹配逻辑
This commit is contained in:
@@ -64,6 +64,9 @@ describe('AIBuiltinToolsCatalog', () => {
|
||||
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_sql_risk');
|
||||
expect(markup).toContain('WHERE 条件');
|
||||
expect(markup).toContain('排查应用日志');
|
||||
expect(markup).toContain('inspect_app_logs');
|
||||
expect(markup).toContain('排查连接失败与冷却');
|
||||
|
||||
@@ -135,6 +135,11 @@ const BUILTIN_TOOL_FLOWS = [
|
||||
steps: 'inspect_recent_sql_activity → inspect_recent_sql_logs → inspect_current_connection',
|
||||
description: '适合先看最近到底以读还是写为主、有没有 DDL 或删除、哪个库最近报错最多,再决定继续下钻哪条日志或哪个连接。',
|
||||
},
|
||||
{
|
||||
title: 'SQL 风险预检',
|
||||
steps: 'inspect_sql_risk → inspect_ai_safety → execute_sql',
|
||||
description: '适合用户要求执行、删除、更新、DDL 或批量 SQL 前,先检查语句数量、写入/DDL 风险、WHERE 条件和当前安全策略,再决定是否需要用户确认。',
|
||||
},
|
||||
{
|
||||
title: '排查应用日志',
|
||||
steps: 'inspect_app_logs → inspect_mcp_setup / inspect_saved_connections / inspect_current_connection',
|
||||
|
||||
@@ -794,6 +794,56 @@ describe('aiLocalToolExecutor', () => {
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inspects SQL risk from the active query tab and applies the AI safety check', async () => {
|
||||
const checkSQL = vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
operationType: 'UPDATE',
|
||||
});
|
||||
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_sql_risk', {}),
|
||||
connections: [buildConnection()],
|
||||
tabs: [{
|
||||
id: 'tab-risk-1',
|
||||
title: '批量更新',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'crm',
|
||||
query: 'UPDATE users SET status = 0',
|
||||
}],
|
||||
activeTabId: 'tab-risk-1',
|
||||
mcpTools: [],
|
||||
toolContextMap: new Map(),
|
||||
runtime: {
|
||||
getDatabases: vi.fn(),
|
||||
getTables: vi.fn(),
|
||||
checkSQL,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = JSON.parse(result.content);
|
||||
expect(result.success).toBe(true);
|
||||
expect(checkSQL).toHaveBeenCalledWith('UPDATE users SET status = 0');
|
||||
expect(payload).toMatchObject({
|
||||
hasSql: true,
|
||||
source: 'active_tab',
|
||||
riskLevel: 'critical',
|
||||
requiresUserConfirmation: true,
|
||||
safetyCheck: {
|
||||
allowed: false,
|
||||
operationType: 'UPDATE',
|
||||
},
|
||||
activeTab: {
|
||||
id: 'tab-risk-1',
|
||||
connectionName: '主库',
|
||||
dbName: 'crm',
|
||||
},
|
||||
});
|
||||
expect(payload.activityKinds).toContain('write');
|
||||
expect(payload.warnings).toContain('UPDATE 缺少 WHERE 条件,可能更新整表数据');
|
||||
expect(payload.warnings).toContain('当前 AI 安全策略不允许执行 UPDATE 类型 SQL');
|
||||
});
|
||||
|
||||
it('returns a cross-table column summary for get_all_columns', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('get_all_columns', {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
buildRecentSqlActivitySnapshot,
|
||||
buildRecentSqlLogsSnapshot,
|
||||
} from './aiSqlLogInsights';
|
||||
import { buildSqlRiskSnapshot } from './aiSqlRiskInsights';
|
||||
import {
|
||||
buildActiveTabSnapshot,
|
||||
buildWorkspaceTabsSnapshot,
|
||||
@@ -251,6 +252,26 @@ export async function executeSnapshotInspectionToolCall(
|
||||
})),
|
||||
success: true,
|
||||
};
|
||||
case 'inspect_sql_risk': {
|
||||
const candidateSql = String(args.sql || '').trim();
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||
const activeTabSql = activeTab?.type === 'query' ? String(activeTab.query || '').trim() : '';
|
||||
const sqlForCheck = candidateSql || activeTabSql;
|
||||
const safetyCheck = sqlForCheck && typeof runtime?.checkSQL === 'function'
|
||||
? await runtime.checkSQL(sqlForCheck)
|
||||
: undefined;
|
||||
return {
|
||||
content: JSON.stringify(buildSqlRiskSnapshot({
|
||||
sql: candidateSql,
|
||||
previewCharLimit: args.previewCharLimit,
|
||||
tabs,
|
||||
activeTabId,
|
||||
connections,
|
||||
safetyCheck,
|
||||
})),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'inspect_app_logs': {
|
||||
const readResult = typeof runtime?.readAppLogTail === 'function'
|
||||
? await runtime.readAppLogTail(Number(args.lineLimit) || 80, String(args.keyword || ''))
|
||||
@@ -354,6 +375,7 @@ export async function executeSnapshotInspectionToolCall(
|
||||
inspect_ai_context: '读取当前 AI 上下文失败',
|
||||
inspect_recent_sql_logs: '获取最近 SQL 日志失败',
|
||||
inspect_recent_sql_activity: '汇总最近 SQL 活动失败',
|
||||
inspect_sql_risk: '检查 SQL 风险失败',
|
||||
inspect_app_logs: '读取 GoNavi 应用日志失败',
|
||||
inspect_recent_connection_failures: '汇总最近连接失败记录失败',
|
||||
inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败',
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AISnapshotInspectionRuntime {
|
||||
getShortcutPlatform?: () => Promise<ShortcutPlatform | undefined>;
|
||||
readAppLogTail?: (lineLimit: number, keyword: string) => Promise<any>;
|
||||
readSQLFile?: (filePath: string) => Promise<any>;
|
||||
checkSQL?: (sql: string) => Promise<{ allowed?: boolean; operationType?: string } | undefined>;
|
||||
}
|
||||
|
||||
export interface SnapshotInspectionResult {
|
||||
|
||||
66
frontend/src/components/ai/aiSqlRiskInsights.test.ts
Normal file
66
frontend/src/components/ai/aiSqlRiskInsights.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSqlRiskSnapshot } from './aiSqlRiskInsights';
|
||||
|
||||
const connections = [{
|
||||
id: 'conn-1',
|
||||
name: '主库',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
},
|
||||
}];
|
||||
|
||||
describe('aiSqlRiskInsights', () => {
|
||||
it('classifies multi-statement destructive SQL as critical', () => {
|
||||
const snapshot = buildSqlRiskSnapshot({
|
||||
sql: 'DELETE FROM accounts; SELECT * FROM accounts;',
|
||||
connections,
|
||||
});
|
||||
|
||||
expect(snapshot.hasSql).toBe(true);
|
||||
expect(snapshot.source).toBe('argument');
|
||||
expect(snapshot.statementCount).toBe(2);
|
||||
expect(snapshot.riskLevel).toBe('critical');
|
||||
expect(snapshot.requiresUserConfirmation).toBe(true);
|
||||
expect(snapshot.activityKinds).toContain('write');
|
||||
expect(snapshot.activityKinds).toContain('read');
|
||||
expect(snapshot.warnings).toContain('DELETE 缺少 WHERE 条件,可能删除整表数据');
|
||||
expect(snapshot.warnings.join('\n')).toContain('批量执行前应逐条确认影响范围');
|
||||
});
|
||||
|
||||
it('reads SQL from the active query tab and includes safety check result', () => {
|
||||
const snapshot = buildSqlRiskSnapshot({
|
||||
tabs: [{
|
||||
id: 'tab-1',
|
||||
title: '用户更新',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'crm',
|
||||
query: 'UPDATE users SET status = 0',
|
||||
}],
|
||||
activeTabId: 'tab-1',
|
||||
connections,
|
||||
safetyCheck: {
|
||||
allowed: false,
|
||||
operationType: 'UPDATE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.hasSql).toBe(true);
|
||||
expect(snapshot.source).toBe('active_tab');
|
||||
expect(snapshot.activeTab).toMatchObject({
|
||||
id: 'tab-1',
|
||||
title: '用户更新',
|
||||
connectionName: '主库',
|
||||
connectionType: 'mysql',
|
||||
dbName: 'crm',
|
||||
});
|
||||
expect(snapshot.riskLevel).toBe('critical');
|
||||
expect(snapshot.safetyCheck).toMatchObject({ allowed: false, operationType: 'UPDATE' });
|
||||
expect(snapshot.warnings).toContain('UPDATE 缺少 WHERE 条件,可能更新整表数据');
|
||||
expect(snapshot.warnings).toContain('当前 AI 安全策略不允许执行 UPDATE 类型 SQL');
|
||||
});
|
||||
});
|
||||
283
frontend/src/components/ai/aiSqlRiskInsights.ts
Normal file
283
frontend/src/components/ai/aiSqlRiskInsights.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { SavedConnection, TabData } from '../../types';
|
||||
import { findSqlStatementRanges } from '../../utils/sqlStatementSelection';
|
||||
|
||||
type SqlRiskLevel = 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
type SqlActivityKind = 'read' | 'write' | 'ddl' | 'transaction' | 'session' | 'routine' | 'other';
|
||||
|
||||
interface SqlSafetyCheckResult {
|
||||
allowed?: boolean;
|
||||
operationType?: string;
|
||||
}
|
||||
|
||||
const SQL_PREVIEW_LIMIT = 12000;
|
||||
|
||||
const READ_TOKENS = new Set(['select', 'show', 'describe', 'desc', 'explain', 'with']);
|
||||
const WRITE_TOKENS = new Set(['insert', 'update', 'delete', 'merge', 'replace', 'upsert']);
|
||||
const DDL_TOKENS = new Set(['create', 'alter', 'drop', 'truncate', 'rename']);
|
||||
const TRANSACTION_TOKENS = new Set(['begin', 'start', 'commit', 'rollback', 'savepoint', 'release']);
|
||||
const SESSION_TOKENS = new Set(['set', 'use', 'reset']);
|
||||
const ROUTINE_TOKENS = new Set(['call', 'exec', 'execute']);
|
||||
|
||||
const stripCommentsAndLiterals = (sql: string): string => {
|
||||
const text = String(sql || '');
|
||||
let result = '';
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const ch = text[index];
|
||||
const next = index + 1 < text.length ? text[index + 1] : '';
|
||||
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') {
|
||||
inLineComment = false;
|
||||
result += '\n';
|
||||
} else {
|
||||
result += ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
index += 1;
|
||||
inBlockComment = false;
|
||||
}
|
||||
result += ' ';
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && !inBacktick && ch === '-' && next === '-') {
|
||||
inLineComment = true;
|
||||
result += ' ';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && !inBacktick && ch === '/' && next === '*') {
|
||||
inBlockComment = true;
|
||||
result += ' ';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!inDouble && !inBacktick && ch === "'") {
|
||||
inSingle = !inSingle;
|
||||
result += ' ';
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inBacktick && ch === '"') {
|
||||
inDouble = !inDouble;
|
||||
result += ' ';
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && ch === '`') {
|
||||
inBacktick = !inBacktick;
|
||||
result += ' ';
|
||||
continue;
|
||||
}
|
||||
result += (inSingle || inDouble || inBacktick) ? ' ' : ch;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveFirstToken = (sql: string): string => {
|
||||
const stripped = stripCommentsAndLiterals(sql).trim();
|
||||
const match = stripped.match(/^[A-Za-z_][A-Za-z0-9_$#]*/);
|
||||
return match?.[0]?.toLowerCase() || '';
|
||||
};
|
||||
|
||||
const classifySqlActivity = (token: string): SqlActivityKind => {
|
||||
if (READ_TOKENS.has(token)) return 'read';
|
||||
if (WRITE_TOKENS.has(token)) return 'write';
|
||||
if (DDL_TOKENS.has(token)) return 'ddl';
|
||||
if (TRANSACTION_TOKENS.has(token)) return 'transaction';
|
||||
if (SESSION_TOKENS.has(token)) return 'session';
|
||||
if (ROUTINE_TOKENS.has(token)) return 'routine';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const escalateRisk = (current: SqlRiskLevel, next: SqlRiskLevel): SqlRiskLevel => {
|
||||
const order: SqlRiskLevel[] = ['none', 'low', 'medium', 'high', 'critical'];
|
||||
return order.indexOf(next) > order.indexOf(current) ? next : current;
|
||||
};
|
||||
|
||||
const hasWhereClause = (statement: string): boolean =>
|
||||
/\bwhere\b/i.test(stripCommentsAndLiterals(statement));
|
||||
|
||||
const normalizeLimit = (limit: unknown): number => {
|
||||
const value = Math.floor(Number(limit) || SQL_PREVIEW_LIMIT);
|
||||
if (value < 200) return 200;
|
||||
if (value > 40000) return 40000;
|
||||
return value;
|
||||
};
|
||||
|
||||
const buildStatementRisk = (statement: string) => {
|
||||
const token = resolveFirstToken(statement);
|
||||
const activityKind = classifySqlActivity(token);
|
||||
const normalized = stripCommentsAndLiterals(statement);
|
||||
const warnings: string[] = [];
|
||||
let riskLevel: SqlRiskLevel = 'low';
|
||||
|
||||
if (!token) {
|
||||
riskLevel = 'none';
|
||||
warnings.push('未识别到有效 SQL 操作关键字');
|
||||
}
|
||||
if (activityKind === 'write') {
|
||||
riskLevel = escalateRisk(riskLevel, 'high');
|
||||
warnings.push('该语句会修改数据,执行前应确认目标库、条件和影响范围');
|
||||
}
|
||||
if (activityKind === 'ddl') {
|
||||
riskLevel = escalateRisk(riskLevel, 'high');
|
||||
warnings.push('该语句会修改数据库结构或对象,建议先备份并确认回滚方案');
|
||||
}
|
||||
if (activityKind === 'routine') {
|
||||
riskLevel = escalateRisk(riskLevel, 'medium');
|
||||
warnings.push('该语句会调用例程或过程,可能存在隐式写入或副作用');
|
||||
}
|
||||
if (/^\s*delete\b/i.test(normalized) && !hasWhereClause(statement)) {
|
||||
riskLevel = escalateRisk(riskLevel, 'critical');
|
||||
warnings.push('DELETE 缺少 WHERE 条件,可能删除整表数据');
|
||||
}
|
||||
if (/^\s*update\b/i.test(normalized) && !hasWhereClause(statement)) {
|
||||
riskLevel = escalateRisk(riskLevel, 'critical');
|
||||
warnings.push('UPDATE 缺少 WHERE 条件,可能更新整表数据');
|
||||
}
|
||||
if (/\btruncate\s+(?:table\s+)?[A-Za-z0-9_`"[\].]+/i.test(normalized)) {
|
||||
riskLevel = escalateRisk(riskLevel, 'critical');
|
||||
warnings.push('TRUNCATE 会快速清空表数据,通常不可按行回滚');
|
||||
}
|
||||
if (/\bdrop\s+(database|schema|table|view|materialized\s+view)\b/i.test(normalized)) {
|
||||
riskLevel = escalateRisk(riskLevel, 'critical');
|
||||
warnings.push('DROP 会删除数据库对象,执行前必须确认对象和备份');
|
||||
}
|
||||
if (/\bgrant\b|\brevoke\b/i.test(normalized)) {
|
||||
riskLevel = escalateRisk(riskLevel, 'high');
|
||||
warnings.push('GRANT / REVOKE 会改变权限边界,应确认授权对象和范围');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
activityKind,
|
||||
riskLevel,
|
||||
warnings,
|
||||
preview: statement.trim().slice(0, 1000),
|
||||
charCount: statement.trim().length,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveActiveSqlSource = (params: {
|
||||
sql?: string;
|
||||
tabs?: TabData[];
|
||||
activeTabId?: string | null;
|
||||
}) => {
|
||||
const explicitSql = String(params.sql || '').trim();
|
||||
if (explicitSql) {
|
||||
return {
|
||||
source: 'argument' as const,
|
||||
sql: explicitSql,
|
||||
activeTab: null as TabData | null,
|
||||
};
|
||||
}
|
||||
|
||||
const activeTab = (params.tabs || []).find((tab) => tab.id === params.activeTabId) || null;
|
||||
const tabSql = activeTab?.type === 'query' ? String(activeTab.query || '').trim() : '';
|
||||
return {
|
||||
source: activeTab ? 'active_tab' as const : 'none' as const,
|
||||
sql: tabSql,
|
||||
activeTab,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSqlRiskSnapshot = (params: {
|
||||
sql?: string;
|
||||
previewCharLimit?: unknown;
|
||||
tabs?: TabData[];
|
||||
activeTabId?: string | null;
|
||||
connections: SavedConnection[];
|
||||
safetyCheck?: SqlSafetyCheckResult;
|
||||
}) => {
|
||||
const { source, sql, activeTab } = resolveActiveSqlSource({
|
||||
sql: params.sql,
|
||||
tabs: params.tabs,
|
||||
activeTabId: params.activeTabId,
|
||||
});
|
||||
const previewLimit = normalizeLimit(params.previewCharLimit);
|
||||
const connection = activeTab
|
||||
? params.connections.find((item) => item.id === activeTab.connectionId)
|
||||
: undefined;
|
||||
|
||||
if (!sql) {
|
||||
return {
|
||||
hasSql: false,
|
||||
source,
|
||||
message: activeTab
|
||||
? '当前活动页签不是 SQL 查询页签,或编辑区没有 SQL 内容'
|
||||
: '未传入 SQL,且当前没有可读取的活动 SQL 查询页签',
|
||||
activeTab: activeTab ? {
|
||||
id: activeTab.id,
|
||||
title: activeTab.title,
|
||||
type: activeTab.type,
|
||||
} : null,
|
||||
safetyCheck: params.safetyCheck || null,
|
||||
riskLevel: 'none' as SqlRiskLevel,
|
||||
warnings: [],
|
||||
nextActions: ['先传入 sql 参数,或切换到包含 SQL 草稿的查询页签'],
|
||||
};
|
||||
}
|
||||
|
||||
const statements = findSqlStatementRanges(sql).map((range) => range.text.trim()).filter(Boolean);
|
||||
const statementRisks = statements.map(buildStatementRisk);
|
||||
let riskLevel: SqlRiskLevel = statements.length > 0 ? 'low' : 'none';
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (statements.length > 1) {
|
||||
riskLevel = escalateRisk(riskLevel, 'medium');
|
||||
warnings.push(`检测到 ${statements.length} 条 SQL 语句,批量执行前应逐条确认影响范围`);
|
||||
}
|
||||
|
||||
for (const statementRisk of statementRisks) {
|
||||
riskLevel = escalateRisk(riskLevel, statementRisk.riskLevel);
|
||||
for (const warning of statementRisk.warnings) {
|
||||
if (!warnings.includes(warning)) warnings.push(warning);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.safetyCheck?.allowed === false) {
|
||||
riskLevel = escalateRisk(riskLevel, 'high');
|
||||
warnings.push(`当前 AI 安全策略不允许执行 ${params.safetyCheck.operationType || '该'} 类型 SQL`);
|
||||
}
|
||||
|
||||
const activityKinds = Array.from(new Set(statementRisks.map((item) => item.activityKind)));
|
||||
const requiresUserConfirmation = activityKinds.some((kind) => kind === 'write' || kind === 'ddl' || kind === 'routine')
|
||||
|| riskLevel === 'critical';
|
||||
|
||||
return {
|
||||
hasSql: true,
|
||||
source,
|
||||
sqlPreview: sql.slice(0, previewLimit),
|
||||
sqlCharCount: sql.length,
|
||||
sqlTruncated: sql.length > previewLimit,
|
||||
statementCount: statements.length,
|
||||
activityKinds,
|
||||
riskLevel,
|
||||
requiresUserConfirmation,
|
||||
safetyCheck: params.safetyCheck || null,
|
||||
activeTab: activeTab ? {
|
||||
id: activeTab.id,
|
||||
title: activeTab.title,
|
||||
type: activeTab.type,
|
||||
connectionId: activeTab.connectionId,
|
||||
connectionName: connection?.name || '',
|
||||
connectionType: connection?.config?.type || '',
|
||||
dbName: activeTab.dbName || '',
|
||||
filePath: activeTab.filePath || '',
|
||||
readOnly: activeTab.readOnly === true,
|
||||
} : null,
|
||||
statements: statementRisks,
|
||||
warnings,
|
||||
nextActions: warnings.length > 0
|
||||
? ['先向用户说明风险点,再要求用户确认是否继续', '写入或 DDL 语句应先确认 WHERE、备份、目标库和影响范围']
|
||||
: ['只读查询风险较低,仍建议先核对目标连接和库名'],
|
||||
};
|
||||
};
|
||||
@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
|
||||
connections: [connections[0]],
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', '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_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
|
||||
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', '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_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
|
||||
skills,
|
||||
userPromptSettings,
|
||||
});
|
||||
@@ -90,6 +90,7 @@ describe('buildAISystemContextMessages', () => {
|
||||
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_sql_risk');
|
||||
expect(joined).toContain('inspect_recent_connection_failures 读取真实连接失败总结');
|
||||
expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部');
|
||||
expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录');
|
||||
|
||||
@@ -152,6 +152,12 @@ export const appendDatabaseInspectionGuidanceMessages = (
|
||||
'inspect_recent_sql_activity',
|
||||
'如果用户提到“最近都执行了什么”“是不是刚删过数据”“最近主要在查还是在改”“哪个库最近报错最多”,优先调用 inspect_recent_sql_activity 先读最近 SQL 活动总结,再决定是否继续下钻 inspect_recent_sql_logs 看具体语句。',
|
||||
);
|
||||
appendGuidanceIfToolAvailable(
|
||||
messages,
|
||||
availableToolNames,
|
||||
'inspect_sql_risk',
|
||||
'如果用户要求你执行、删除、更新、DDL、批量 SQL,或问“这条 SQL 能不能跑/危险不危险”,优先调用 inspect_sql_risk 检查当前编辑区或传入 SQL 的语句数量、写入/DDL 风险、WHERE 条件和安全策略结果;发现 high/critical 风险时先解释风险并让用户确认,不要直接推进执行。',
|
||||
);
|
||||
appendGuidanceIfToolAvailable(
|
||||
messages,
|
||||
availableToolNames,
|
||||
|
||||
@@ -378,6 +378,29 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_sql_risk",
|
||||
icon: "🛑",
|
||||
desc: "检查当前或指定 SQL 的执行风险",
|
||||
detail:
|
||||
"读取传入 SQL 或当前活动查询页签内容,识别多语句、写入、DDL、DELETE/UPDATE 无 WHERE、DROP/TRUNCATE 等风险,并结合当前 AI 安全策略返回是否允许执行。适合用户让 AI 执行、解释风险、确认能不能跑某条 SQL 前先做一次安全体检。",
|
||||
params: "sql?(默认读取当前活动查询页签), previewCharLimit?",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "inspect_sql_risk",
|
||||
description:
|
||||
"检查传入 SQL 或当前活动查询页签 SQL 的执行风险,返回语句数量、活动类型、风险级别、危险点、是否需要用户确认,以及当前 AI 安全策略检查结果。适用于用户要求执行、删除、更新、DDL、批量 SQL、或询问某条 SQL 能不能跑时,先读取这份风险快照再回答或继续执行。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "可选,要检查的 SQL;不传时默认读取当前活动查询页签的 SQL 草稿" },
|
||||
previewCharLimit: { type: "number", description: "可选,SQL 预览最多返回多少字符,默认 12000,最大 40000" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_app_logs",
|
||||
icon: "🪵",
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('aiToolRegistry', () => {
|
||||
|
||||
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 sqlRiskTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_risk');
|
||||
const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs');
|
||||
const connectionFailureTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures');
|
||||
const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error');
|
||||
@@ -133,6 +134,8 @@ describe('aiToolRegistry', () => {
|
||||
|
||||
expect(recentActivityTool?.desc).toContain('最近 SQL 活动');
|
||||
expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动');
|
||||
expect(sqlRiskTool?.desc).toContain('SQL 的执行风险');
|
||||
expect(sqlRiskTool?.tool.function.description).toContain('危险点');
|
||||
expect(appLogTool?.desc).toContain('GoNavi 应用日志');
|
||||
expect(appLogTool?.tool.function.description).toContain('应用日志');
|
||||
expect(connectionFailureTool?.desc).toContain('连接失败');
|
||||
@@ -177,6 +180,7 @@ describe('aiToolRegistry', () => {
|
||||
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_sql_risk')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true);
|
||||
|
||||
@@ -700,7 +700,7 @@ describe('sidebarLocate', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not guess a schema-qualified view when an unqualified locate request is ambiguous', () => {
|
||||
it('prefers the current database schema when an unqualified view request matches multiple schemas', () => {
|
||||
const target = resolveSidebarLocateTarget({
|
||||
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
|
||||
connectionId: 'conn-1',
|
||||
@@ -753,6 +753,68 @@ describe('sidebarLocate', () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
|
||||
'conn-1',
|
||||
'conn-1-SYSDBA',
|
||||
'conn-1-SYSDBA-schema-SYSDBA',
|
||||
'conn-1-SYSDBA-schema-SYSDBA-views',
|
||||
'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not guess a schema-qualified view when no current-schema preference resolves ambiguity', () => {
|
||||
const target = resolveSidebarLocateTarget({
|
||||
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'SYSDBA',
|
||||
tableName: 'V_ACCOUNT',
|
||||
objectGroup: 'views',
|
||||
}, { groupBySchema: true });
|
||||
|
||||
const tree = [
|
||||
{
|
||||
key: 'conn-1',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-SYSDBA',
|
||||
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-SYSDBA-schema-APP',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-SYSDBA-schema-APP-views',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-SYSDBA-view-APP.V_ACCOUNT',
|
||||
type: 'view',
|
||||
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'APP.V_ACCOUNT', schemaName: 'APP' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'conn-1-SYSDBA-schema-REPORT',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-SYSDBA-schema-REPORT-views',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT',
|
||||
type: 'view',
|
||||
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'REPORT.V_ACCOUNT', schemaName: 'REPORT' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findSidebarNodePathForLocate(tree, target)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface SidebarLocateTabLike {
|
||||
}
|
||||
|
||||
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
|
||||
const normalizeLocateName = (value: string): string => toTrimmedString(value).toLowerCase();
|
||||
|
||||
const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedString(value).replace(/\\/g, '/');
|
||||
|
||||
@@ -265,28 +266,27 @@ const matchesLocateObjectName = (
|
||||
const targetObject = targetParsed.objectName || target.tableName;
|
||||
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
|
||||
const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName;
|
||||
const normalize = (value: string): string => toTrimmedString(value).toLowerCase();
|
||||
|
||||
if (normalize(normalizedNodeName) === normalize(target.tableName)) return true;
|
||||
if (normalizeLocateName(normalizedNodeName) === normalizeLocateName(target.tableName)) return true;
|
||||
|
||||
if (
|
||||
resolvedTargetSchema
|
||||
&& !resolvedNodeSchema
|
||||
&& normalize(resolvedTargetSchema) === normalize(target.dbName)
|
||||
&& normalize(nodeObject) === normalize(targetObject)
|
||||
&& normalizeLocateName(resolvedTargetSchema) === normalizeLocateName(target.dbName)
|
||||
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolvedTargetSchema) {
|
||||
if (options.allowUnqualifiedSchemaMatch) {
|
||||
return normalize(nodeObject) === normalize(targetObject);
|
||||
return normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
|
||||
}
|
||||
return !resolvedNodeSchema && normalize(nodeObject) === normalize(targetObject);
|
||||
return !resolvedNodeSchema && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
|
||||
}
|
||||
|
||||
return normalize(resolvedNodeSchema) === normalize(resolvedTargetSchema)
|
||||
&& normalize(nodeObject) === normalize(targetObject);
|
||||
return normalizeLocateName(resolvedNodeSchema) === normalizeLocateName(resolvedTargetSchema)
|
||||
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
|
||||
};
|
||||
|
||||
const matchesLocateObjectNode = (
|
||||
@@ -441,6 +441,40 @@ const shouldFallbackViewLocateToTableNode = (target: SidebarLocateTarget): boole
|
||||
target.objectGroup === 'views' || target.objectGroup === 'materializedViews'
|
||||
);
|
||||
|
||||
const selectPreferredSidebarLocatePath = (
|
||||
paths: string[][],
|
||||
target: SidebarLocateTarget,
|
||||
): string[] | null => {
|
||||
if (paths.length === 1) return paths[0];
|
||||
if (paths.length === 0 || target.objectGroup === 'externalSqlFiles') return null;
|
||||
|
||||
const targetParsed = splitSidebarQualifiedName(target.tableName);
|
||||
const targetObjectName = normalizeLocateName(targetParsed.objectName || target.tableName);
|
||||
const schemaCandidates = [
|
||||
toTrimmedString(target.schemaName),
|
||||
targetParsed.schemaName,
|
||||
target.dbName,
|
||||
].filter(Boolean);
|
||||
const normalizedSchemas = Array.from(new Set(schemaCandidates.map(normalizeLocateName)));
|
||||
|
||||
for (const normalizedSchema of normalizedSchemas) {
|
||||
const preferredSchemaKey = `${normalizeLocateName(target.databaseKey)}-schema-${normalizedSchema}`;
|
||||
const bySchemaGroup = paths.filter((path) =>
|
||||
path.some((key) => normalizeLocateName(key) === preferredSchemaKey),
|
||||
);
|
||||
if (bySchemaGroup.length === 1) return bySchemaGroup[0];
|
||||
|
||||
const qualifiedSuffix = `${normalizedSchema}.${targetObjectName}`;
|
||||
const byQualifiedLeafKey = paths.filter((path) => {
|
||||
const leafKey = normalizeLocateName(path[path.length - 1] || '');
|
||||
return leafKey.endsWith(qualifiedSuffix);
|
||||
});
|
||||
if (byQualifiedLeafKey.length === 1) return byQualifiedLeafKey[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findSidebarNodePathForLocate = (
|
||||
nodes: SidebarLocateTreeNodeLike[],
|
||||
target: SidebarLocateTarget,
|
||||
@@ -452,24 +486,27 @@ export const findSidebarNodePathForLocate = (
|
||||
if (strictPath) return strictPath;
|
||||
|
||||
const visualIdentityPaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, target);
|
||||
if (visualIdentityPaths.length === 1) return visualIdentityPaths[0];
|
||||
const visualIdentityPath = selectPreferredSidebarLocatePath(visualIdentityPaths, target);
|
||||
if (visualIdentityPath) return visualIdentityPath;
|
||||
|
||||
if (shouldFallbackViewLocateToTableNode(target)) {
|
||||
const tableLikeTarget = { ...target, objectGroup: 'tables' as const };
|
||||
const tableLikePaths = collectSidebarNodePathsForLocateByObject(nodes, tableLikeTarget);
|
||||
if (tableLikePaths.length === 1) return tableLikePaths[0];
|
||||
const tableLikePath = selectPreferredSidebarLocatePath(tableLikePaths, target);
|
||||
if (tableLikePath) return tableLikePath;
|
||||
if (!hasLocateTargetSchema(target)) {
|
||||
const relaxedTableLikePaths = collectSidebarNodePathsForLocateByObject(
|
||||
nodes,
|
||||
tableLikeTarget,
|
||||
{ allowUnqualifiedSchemaMatch: true },
|
||||
);
|
||||
if (relaxedTableLikePaths.length === 1) return relaxedTableLikePaths[0];
|
||||
const relaxedTableLikePath = selectPreferredSidebarLocatePath(relaxedTableLikePaths, target);
|
||||
if (relaxedTableLikePath) return relaxedTableLikePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLocateTargetSchema(target)) return null;
|
||||
|
||||
const relaxedPaths = collectSidebarNodePathsForLocateByObject(nodes, target, { allowUnqualifiedSchemaMatch: true });
|
||||
return relaxedPaths.length === 1 ? relaxedPaths[0] : null;
|
||||
return selectPreferredSidebarLocatePath(relaxedPaths, target);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user