From 48de0b83c4b37e320b78f6d1b4440014982fa179 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 21:56:28 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai-tools):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20SQL=20=E9=A3=8E=E9=99=A9=E9=A2=84=E6=A3=80=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=86=E5=9B=BE=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 inspect_sql_risk 内置工具,识别多语句、写入、DDL、无 WHERE 和安全策略风险 - 在 AI 设置内置工具目录和系统提示中补充 SQL 风险预检链路 - 修复同名视图定位时优先当前数据库 schema 的匹配逻辑 --- .../ai/AIBuiltinToolsCatalog.test.tsx | 3 + .../components/ai/AIBuiltinToolsCatalog.tsx | 5 + .../components/ai/aiLocalToolExecutor.test.ts | 50 ++++ .../ai/aiSnapshotInspectionToolExecutor.ts | 22 ++ .../ai/aiSnapshotInspectionToolTypes.ts | 1 + .../components/ai/aiSqlRiskInsights.test.ts | 66 ++++ .../src/components/ai/aiSqlRiskInsights.ts | 283 ++++++++++++++++++ .../ai/aiSystemContextMessages.test.ts | 3 +- .../ai/aiSystemInspectionGuidance.ts | 6 + .../src/utils/aiBuiltinInspectionToolInfo.ts | 23 ++ frontend/src/utils/aiToolRegistry.test.ts | 4 + frontend/src/utils/sidebarLocate.test.ts | 64 +++- frontend/src/utils/sidebarLocate.ts | 61 +++- 13 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/ai/aiSqlRiskInsights.test.ts create mode 100644 frontend/src/components/ai/aiSqlRiskInsights.ts diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 37f3f83..a754347 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -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('排查连接失败与冷却'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index c071e4f..af30012 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -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', diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 8586be9..b3f7aac 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -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', { diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index eb5d63a..b4dc58c 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -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 渲染异常失败', diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts b/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts index cea9c90..0bb0b37 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts @@ -24,6 +24,7 @@ export interface AISnapshotInspectionRuntime { getShortcutPlatform?: () => Promise; readAppLogTail?: (lineLimit: number, keyword: string) => Promise; readSQLFile?: (filePath: string) => Promise; + checkSQL?: (sql: string) => Promise<{ allowed?: boolean; operationType?: string } | undefined>; } export interface SnapshotInspectionResult { diff --git a/frontend/src/components/ai/aiSqlRiskInsights.test.ts b/frontend/src/components/ai/aiSqlRiskInsights.test.ts new file mode 100644 index 0000000..4e34d95 --- /dev/null +++ b/frontend/src/components/ai/aiSqlRiskInsights.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/components/ai/aiSqlRiskInsights.ts b/frontend/src/components/ai/aiSqlRiskInsights.ts new file mode 100644 index 0000000..61e408e --- /dev/null +++ b/frontend/src/components/ai/aiSqlRiskInsights.ts @@ -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、备份、目标库和影响范围'] + : ['只读查询风险较低,仍建议先核对目标连接和库名'], + }; +}; diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 5f921e1..e0cc8cb 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_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 读取最近一次被隔离的前端渲染异常记录'); diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index c6be824..ceacb49 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -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, diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 49ed779..063579d 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -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: "🪵", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index d544fc2..8456346 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -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); diff --git a/frontend/src/utils/sidebarLocate.test.ts b/frontend/src/utils/sidebarLocate.test.ts index c14e4c7..06130bb 100644 --- a/frontend/src/utils/sidebarLocate.test.ts +++ b/frontend/src/utils/sidebarLocate.test.ts @@ -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(); }); diff --git a/frontend/src/utils/sidebarLocate.ts b/frontend/src/utils/sidebarLocate.ts index 795b6c4..d34d239 100644 --- a/frontend/src/utils/sidebarLocate.ts +++ b/frontend/src/utils/sidebarLocate.ts @@ -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); };