diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ac28ab7..c441be8 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -763,6 +763,7 @@ type PendingSqlEditorTransaction = { commitMode: 'manual' | 'auto'; autoCommitDelayMs: number; createdAt: number; + autoCommitDueAt?: number | null; }; const normalizeEditorPosition = (position: any): { lineNumber: number; column: number } | null => { @@ -2048,6 +2049,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const setQueryOptions = useStore(state => state.setQueryOptions); const sqlEditorTransactionOptions = useStore(state => state.sqlEditorTransactionOptions); const setSqlEditorTransactionOptions = useStore(state => state.setSqlEditorTransactionOptions); + const setSqlEditorPendingTransaction = useStore(state => state.setSqlEditorPendingTransaction); const [isResultPanelVisible, setIsResultPanelVisible] = useState( () => tab.resultPanelVisible === true ); @@ -2110,7 +2112,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const updatePendingSqlTransaction = useCallback((transaction: PendingSqlEditorTransaction | null) => { pendingSqlTransactionRef.current = transaction; setPendingSqlTransaction(transaction); - }, []); + setSqlEditorPendingTransaction(tab.id, transaction); + }, [setSqlEditorPendingTransaction, tab.id]); const finishPendingSqlTransaction = useCallback(async ( action: 'commit' | 'rollback', source: 'manual' | 'auto' = 'manual', @@ -2145,11 +2148,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }, [clearSqlEditorAutoCommitTimer, updatePendingSqlTransaction]); const activatePendingSqlTransaction = useCallback((transaction: PendingSqlEditorTransaction) => { clearSqlEditorAutoCommitTimer(); - updatePendingSqlTransaction(transaction); - if (transaction.commitMode !== 'auto') { + const dueAt = transaction.commitMode === 'auto' ? Date.now() + transaction.autoCommitDelayMs : null; + const nextTransaction = { ...transaction, autoCommitDueAt: dueAt }; + updatePendingSqlTransaction(nextTransaction); + if (nextTransaction.commitMode !== 'auto' || !dueAt) { return; } - const dueAt = Date.now() + transaction.autoCommitDelayMs; const updateRemaining = () => { setSqlEditorAutoCommitRemainingSeconds(Math.max(1, Math.ceil((dueAt - Date.now()) / 1000))); }; @@ -2162,8 +2166,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc sqlEditorAutoCommitCountdownRef.current = null; } setSqlEditorAutoCommitRemainingSeconds(null); - void finishPendingSqlTransaction('commit', 'auto', transaction.id); - }, transaction.autoCommitDelayMs); + void finishPendingSqlTransaction('commit', 'auto', nextTransaction.id); + }, nextTransaction.autoCommitDelayMs); }, [clearSqlEditorAutoCommitTimer, finishPendingSqlTransaction, updatePendingSqlTransaction]); useEffect(() => { return () => { @@ -2171,10 +2175,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const transaction = pendingSqlTransactionRef.current; if (transaction?.id) { pendingSqlTransactionRef.current = null; + setSqlEditorPendingTransaction(tab.id, null); void DBRollbackTransaction(transaction.id); } }; - }, [clearSqlEditorAutoCommitTimer]); + }, [clearSqlEditorAutoCommitTimer, setSqlEditorPendingTransaction, tab.id]); const autoFetchVisible = useAutoFetchVisibility(); const currentSavedQuery = useMemo(() => { diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 89f95ab..a29f9e1 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -71,6 +71,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_editor_transaction'); + expect(markup).toContain('待提交事务'); expect(markup).toContain('SQL 风险预检'); expect(markup).toContain('inspect_sql_risk'); expect(markup).toContain('WHERE 条件'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index feba9dd..1761af4 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -145,6 +145,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_recent_sql_activity → inspect_recent_sql_logs → inspect_current_connection', description: '适合先看最近到底以读还是写为主、有没有 DDL 或删除、哪个库最近报错最多,再决定继续下钻哪条日志或哪个连接。', }, + { + title: '核对 SQL 编辑器事务', + steps: 'inspect_sql_editor_transaction → inspect_recent_sql_activity → inspect_sql_risk', + description: '适合先确认 SQL 编辑器 DML 是否会进入托管事务、当前是手动还是自动提交、有没有待提交事务,再解释 update/insert/delete 执行后的提交语义。', + }, { title: 'SQL 风险预检', steps: 'inspect_sql_risk → inspect_ai_safety → execute_sql', diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 738d865..ef39f0b 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -481,6 +481,61 @@ describe('aiLocalToolExecutor', () => { expect(result.content).not.toContain('SELECT * FROM users LIMIT 10'); }); + it('returns sql editor transaction settings, active dml semantics, and pending transactions', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_sql_editor_transaction', {}), + connections: [buildConnection()], + tabs: [{ + id: 'tab-query-1', + title: '订单更新', + type: 'query', + connectionId: 'conn-1', + dbName: 'crm', + query: 'UPDATE orders SET status = \'paid\' WHERE id = 1', + resultPanelVisible: true, + }], + activeTabId: 'tab-query-1', + mcpTools: [], + toolContextMap: new Map(), + sqlLogs: [{ + id: 'log-1', + timestamp: 10, + sql: 'UPDATE orders SET status = \'paid\' WHERE id = 1', + status: 'success', + duration: 42, + dbName: 'crm', + affectedRows: 1, + }], + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getSqlEditorTransactionState: vi.fn().mockResolvedValue({ + commitMode: 'auto', + autoCommitDelayMs: 3000, + pendingTransactions: { + 'tab-query-1': { + id: 'tx-1', + tabId: 'tab-query-1', + commitMode: 'auto', + autoCommitDelayMs: 3000, + createdAt: 1000, + autoCommitDueAt: Date.now() + 3000, + }, + }, + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"commitMode":"auto"'); + expect(result.content).toContain('"transactionAlwaysOnForDML":true'); + expect(result.content).toContain('"usesManagedTransaction":true'); + expect(result.content).toContain('"pendingTransactionCount":1'); + expect(result.content).toContain('"activePendingTransaction"'); + expect(result.content).toContain('自动提交,但 DML 仍会先进入托管事务'); + expect(result.content).toContain('UPDATE orders SET status'); + }); + it('returns a database overview bundle with per-table column previews in one tool call', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_database_bundle', { diff --git a/frontend/src/components/ai/aiLocalToolRuntime.ts b/frontend/src/components/ai/aiLocalToolRuntime.ts index 3009a1c..7e8c8e9 100644 --- a/frontend/src/components/ai/aiLocalToolRuntime.ts +++ b/frontend/src/components/ai/aiLocalToolRuntime.ts @@ -105,6 +105,13 @@ export const buildDefaultLocalToolRuntime = (): AILocalToolRuntime => ({ } return service.AIGetMCPClientInstallStatuses(); }, + getSqlEditorTransactionState: async () => { + const state = useStore.getState(); + return { + ...state.sqlEditorTransactionOptions, + pendingTransactions: state.sqlEditorPendingTransactions, + }; + }, getShortcutOptions: async () => useStore.getState().shortcutOptions, getShortcutPlatform: async () => getShortcutPlatform(isMacLikePlatform()), }); diff --git a/frontend/src/components/ai/aiSlashCommands.test.ts b/frontend/src/components/ai/aiSlashCommands.test.ts index c27717f..7ce3e5b 100644 --- a/frontend/src/components/ai/aiSlashCommands.test.ts +++ b/frontend/src/components/ai/aiSlashCommands.test.ts @@ -20,6 +20,7 @@ describe('aiSlashCommands', () => { expect(commands.some((command) => command.cmd === '/shortcuts')).toBe(true); expect(commands.some((command) => command.cmd === '/applog')).toBe(true); expect(commands.some((command) => command.cmd === '/airender')).toBe(true); + expect(commands.some((command) => command.cmd === '/tx')).toBe(true); }); it('supports filtering by chinese keywords in addition to command prefix', () => { @@ -49,6 +50,12 @@ describe('aiSlashCommands', () => { expect(filterAISlashCommands('/air').map((command) => command.cmd)).toContain('/airender'); }); + it('supports filtering sql editor transaction diagnostics by keyword and command prefix', () => { + expect(filterAISlashCommands('自动提交').map((command) => command.cmd)).toContain('/tx'); + expect(filterAISlashCommands('未提交').map((command) => command.cmd)).toContain('/tx'); + expect(filterAISlashCommands('/tx').map((command) => command.cmd)).toContain('/tx'); + }); + it('supports filtering mcp tool schema diagnostics by keyword and command prefix', () => { expect(filterAISlashCommands('arguments').map((command) => command.cmd)).toContain('/mcptool'); expect(filterAISlashCommands('MCP工具参数').map((command) => command.cmd)).toContain('/mcptool'); @@ -77,6 +84,7 @@ describe('aiSlashCommands', () => { expect(featured).toContain('/mcp'); expect(featured).toContain('/mcpadd'); expect(featured).toContain('/connfail'); + expect(featured).toContain('/tx'); expect(featured).not.toContain('/shortcuts'); }); }); diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index 3ee6dea..d72c1d2 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -58,6 +58,7 @@ export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [ { cmd: '/airender', label: '🧯 AI 渲染异常', desc: '读取最近一次 AI 消息渲染失败记录', prompt: '请先调用 inspect_ai_last_render_error,告诉我最近一次 AI 消息渲染失败记录里是哪条消息、报错摘要是什么,以及下一步该怎么排查。', category: 'diagnose', keywords: ['渲染失败', '气泡空白', 'ai消息', 'render', '白块'] }, { cmd: '/safety', label: '🛡️ 查看写入安全', desc: '确认只读/写入边界和 allowMutating', prompt: '请先调用 inspect_ai_safety,告诉我当前 AI 和 GoNavi MCP 的写入边界、是否只读,以及 execute_sql 是否需要 allowMutating。', category: 'diagnose', keywords: ['安全', '只读', 'allowmutating', 'ddl', 'dml'] }, { cmd: '/activity', label: '🕘 最近 SQL 活动', desc: '总结最近执行、报错和热点', prompt: '请先调用 inspect_recent_sql_activity,帮我总结最近 SQL 活动、错误热点和主要读写类型。', category: 'diagnose', keywords: ['activity', 'sql日志', '最近执行', '报错'] }, + { cmd: '/tx', label: '🔁 SQL 事务状态', desc: '查看 SQL 编辑器提交模式和待提交事务', prompt: '请先调用 inspect_sql_editor_transaction,告诉我 SQL 编辑器当前 DML 托管事务语义、手动/自动提交设置、活动 SQL 页签是否会进入事务、是否有待提交事务,以及下一步应该提交、回滚还是继续执行。', category: 'diagnose', featured: true, keywords: ['事务', 'transaction', '提交', '自动提交', '手动提交', '未提交', 'dml'] }, ]; const buildCommandSearchText = (command: AISlashCommandDefinition): string => [ diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index 08f58cb..fbc5980 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -31,6 +31,7 @@ import { buildRecentSqlActivitySnapshot, buildRecentSqlLogsSnapshot, } from './aiSqlLogInsights'; +import { buildSqlEditorTransactionSnapshot } from './aiSqlEditorTransactionInsights'; import { buildActiveTabSnapshot, buildWorkspaceTabsSnapshot, @@ -300,6 +301,22 @@ export async function executeSnapshotInspectionToolCall( })), success: true, }; + case 'inspect_sql_editor_transaction': { + const transactionState = typeof runtime?.getSqlEditorTransactionState === 'function' + ? await runtime.getSqlEditorTransactionState() + : undefined; + return { + content: JSON.stringify(buildSqlEditorTransactionSnapshot({ + transactionState, + tabs, + activeTabId, + connections, + sqlLogs, + includeSqlPreview: args.includeSqlPreview !== false, + })), + success: true, + }; + } case 'inspect_app_logs': { const readResult = typeof runtime?.readAppLogTail === 'function' ? await runtime.readAppLogTail(Number(args.lineLimit) || 80, String(args.keyword || '')) @@ -403,6 +420,7 @@ export async function executeSnapshotInspectionToolCall( inspect_ai_context: '读取当前 AI 上下文失败', inspect_recent_sql_logs: '获取最近 SQL 日志失败', inspect_recent_sql_activity: '汇总最近 SQL 活动失败', + inspect_sql_editor_transaction: '读取 SQL 编辑器事务状态失败', inspect_sql_risk: '检查 SQL 风险失败', inspect_app_logs: '读取 GoNavi 应用日志失败', inspect_recent_connection_failures: '汇总最近连接失败记录失败', diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts b/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts index 0bb0b37..b04c3c3 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolTypes.ts @@ -16,12 +16,28 @@ export interface AISnapshotInspectionRuntimeState { contextLevel?: string; } +export interface AISqlEditorPendingTransactionRuntimeState { + id: string; + tabId: string; + commitMode: string; + autoCommitDelayMs: number; + createdAt: number; + autoCommitDueAt?: number | null; +} + +export interface AISqlEditorTransactionRuntimeState { + commitMode?: string; + autoCommitDelayMs?: number; + pendingTransactions?: Record; +} + export interface AISnapshotInspectionRuntime { getAIRuntimeState?: () => Promise; getMCPServers?: () => Promise; getMCPClientInstallStatuses?: () => Promise; getShortcutOptions?: () => Promise; getShortcutPlatform?: () => Promise; + getSqlEditorTransactionState?: () => Promise; readAppLogTail?: (lineLimit: number, keyword: string) => Promise; readSQLFile?: (filePath: string) => Promise; checkSQL?: (sql: string) => Promise<{ allowed?: boolean; operationType?: string } | undefined>; diff --git a/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts b/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts new file mode 100644 index 0000000..ade1bd4 --- /dev/null +++ b/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts @@ -0,0 +1,235 @@ +import type { SqlLog } from '../../store'; +import type { SavedConnection, TabData } from '../../types'; +import { findSqlStatementRanges } from '../../utils/sqlStatementSelection'; +import { shouldUseSqlEditorManagedTransaction } from '../../utils/sqlEditorTransaction'; +import type { + AISqlEditorPendingTransactionRuntimeState, + AISqlEditorTransactionRuntimeState, +} from './aiSnapshotInspectionToolTypes'; + +type SqlEditorCommitMode = 'manual' | 'auto'; + +const DEFAULT_AUTO_COMMIT_DELAY_MS = 5000; +const SQL_PREVIEW_LIMIT = 1200; + +const normalizeCommitMode = (value: unknown): SqlEditorCommitMode => + String(value || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'; + +const normalizeDelayMs = (value: unknown): number => { + const delayMs = Number(value); + return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : DEFAULT_AUTO_COMMIT_DELAY_MS; +}; + +const splitStatements = (sql: string): string[] => + findSqlStatementRanges(String(sql || '')) + .map((range) => String(range.text || '').trim()) + .filter(Boolean); + +const hasTransactionControlStatement = (statement: string): boolean => + /^\s*(begin|commit|rollback|savepoint|release)\b/i.test(statement) + || /^\s*start\s+transaction\b/i.test(statement); + +const buildTabSummary = ( + tab: TabData | undefined, + connections: SavedConnection[], +) => { + if (!tab) return null; + const connection = connections.find((item) => item.id === tab.connectionId); + return { + id: tab.id, + title: tab.title, + type: tab.type, + connectionId: tab.connectionId, + connectionName: connection?.name || '', + connectionType: connection?.config?.type || '', + dbName: tab.dbName || '', + filePath: tab.filePath || '', + resultPanelVisible: tab.resultPanelVisible === true, + readOnly: tab.readOnly === true, + }; +}; + +const buildActiveSqlTabSnapshot = (params: { + tabs?: TabData[]; + activeTabId?: string | null; + connections: SavedConnection[]; + includeSqlPreview?: boolean; +}) => { + const { tabs = [], activeTabId = null, connections, includeSqlPreview = true } = params; + const activeTab = tabs.find((tab) => tab.id === activeTabId); + if (!activeTab) { + return { + hasActiveTab: false, + hasSql: false, + message: '当前没有活动页签', + }; + } + if (activeTab.type !== 'query') { + return { + hasActiveTab: true, + hasSql: false, + message: '当前活动页签不是 SQL 编辑器页签', + tab: buildTabSummary(activeTab, connections), + }; + } + + const sql = String(activeTab.query || '').trim(); + const statements = splitStatements(sql); + const hasExplicitTransactionControl = statements.some(hasTransactionControlStatement); + const usesManagedTransaction = shouldUseSqlEditorManagedTransaction(statements); + + return { + hasActiveTab: true, + hasSql: sql.length > 0, + tab: buildTabSummary(activeTab, connections), + sqlPreview: includeSqlPreview ? sql.slice(0, SQL_PREVIEW_LIMIT) : '', + sqlCharCount: sql.length, + sqlTruncated: includeSqlPreview && sql.length > SQL_PREVIEW_LIMIT, + statementCount: statements.length, + hasExplicitTransactionControl, + usesManagedTransaction, + transactionSemantics: usesManagedTransaction + ? '执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时会先进入 SQL 编辑器托管事务;提交设置只决定事务执行成功后何时 COMMIT。' + : hasExplicitTransactionControl + ? '检测到用户显式事务控制语句,GoNavi 不会再包一层 SQL 编辑器托管事务。' + : '当前 SQL 不会触发 SQL 编辑器托管事务;只读查询仍走普通查询路径。', + }; +}; + +const buildPendingTransactionPreview = (params: { + item: AISqlEditorPendingTransactionRuntimeState; + tabs?: TabData[]; + connections: SavedConnection[]; + now: number; +}) => { + const { item, tabs = [], connections, now } = params; + const tab = tabs.find((candidate) => candidate.id === item.tabId); + const commitMode = normalizeCommitMode(item.commitMode); + const dueAt = Number(item.autoCommitDueAt || 0); + const remainingMs = commitMode === 'auto' && dueAt > 0 + ? Math.max(0, dueAt - now) + : null; + + return { + id: item.id, + tabId: item.tabId, + tab: buildTabSummary(tab, connections), + commitMode, + autoCommitDelayMs: normalizeDelayMs(item.autoCommitDelayMs), + createdAt: Number(item.createdAt) || 0, + autoCommitDueAt: dueAt || null, + autoCommitRemainingMs: remainingMs, + }; +}; + +const isRelevantSqlEditorTransactionLog = (log: SqlLog): boolean => { + const sql = String(log.sql || ''); + const statements = splitStatements(sql); + if (shouldUseSqlEditorManagedTransaction(statements)) return true; + if (statements.some(hasTransactionControlStatement)) return true; + return /\b(transaction|commit|rollback)\b/i.test(sql) + || /事务|提交|回滚|transaction|commit|rollback/i.test(String(log.message || '')); +}; + +const buildRecentLogPreview = (log: SqlLog) => ({ + id: log.id, + timestamp: log.timestamp, + status: log.status, + duration: log.duration, + dbName: log.dbName || '', + affectedRows: typeof log.affectedRows === 'number' ? log.affectedRows : null, + sqlPreview: String(log.sql || '').trim().slice(0, 1000), + message: log.message || '', +}); + +export const buildSqlEditorTransactionSnapshot = (params: { + transactionState?: AISqlEditorTransactionRuntimeState; + tabs?: TabData[]; + activeTabId?: string | null; + connections: SavedConnection[]; + sqlLogs?: SqlLog[]; + includeSqlPreview?: boolean; + now?: number; +}) => { + const { + transactionState, + tabs = [], + activeTabId = null, + connections, + sqlLogs = [], + includeSqlPreview = true, + now = Date.now(), + } = params; + const commitMode = normalizeCommitMode(transactionState?.commitMode); + const autoCommitDelayMs = normalizeDelayMs(transactionState?.autoCommitDelayMs); + const pendingTransactions = Object.values(transactionState?.pendingTransactions || {}) + .map((item) => buildPendingTransactionPreview({ item, tabs, connections, now })) + .sort((left, right) => right.createdAt - left.createdAt); + const activePendingTransaction = pendingTransactions.find((item) => item.tabId === activeTabId) || null; + const activeSqlTab = buildActiveSqlTabSnapshot({ + tabs, + activeTabId, + connections, + includeSqlPreview, + }); + const activeUsesManagedTransaction = activeSqlTab.hasActiveTab + && activeSqlTab.hasSql + && 'usesManagedTransaction' in activeSqlTab + && activeSqlTab.usesManagedTransaction === true; + const activeHasExplicitTransactionControl = activeSqlTab.hasActiveTab + && activeSqlTab.hasSql + && 'hasExplicitTransactionControl' in activeSqlTab + && activeSqlTab.hasExplicitTransactionControl === true; + const recentRelevantLogs = sqlLogs + .filter(isRelevantSqlEditorTransactionLog) + .slice(0, 8) + .map(buildRecentLogPreview); + + const warnings: string[] = []; + if (pendingTransactions.length > 0) { + warnings.push(`当前有 ${pendingTransactions.length} 个 SQL 编辑器托管事务待提交或回滚`); + } + if (activePendingTransaction) { + warnings.push('当前活动 SQL 页签已有待处理事务,继续执行新的 DML 前应先提交或回滚'); + } + if (activeUsesManagedTransaction && commitMode === 'auto') { + warnings.push('当前设置为自动提交,但 DML 仍会先进入托管事务,只是在延迟到期后自动 COMMIT'); + } + if (activeHasExplicitTransactionControl) { + warnings.push('当前 SQL 已包含显式事务控制,SQL 编辑器不会再接管提交/回滚'); + } + + const nextActions: string[] = []; + if (activePendingTransaction) { + nextActions.push('先让用户在结果区事务条点击“提交”或“回滚”,或等待自动提交倒计时结束'); + } else if (pendingTransactions.length > 0) { + nextActions.push('如要继续执行 DML,先切回对应 SQL 页签处理待提交事务'); + } + if (activeUsesManagedTransaction) { + nextActions.push(commitMode === 'auto' + ? `说明当前 DML 会先开启托管事务,执行成功后约 ${Math.round(autoCommitDelayMs / 1000)} 秒自动提交` + : '说明当前 DML 会先开启托管事务,执行成功后需要手动点击提交或回滚'); + } + if (!activeSqlTab.hasActiveTab || !activeSqlTab.hasSql) { + nextActions.push('先切换到包含 SQL 草稿的查询页签,或让用户贴出要执行的 SQL'); + } + if (recentRelevantLogs.length > 0) { + nextActions.push('结合 recentRelevantLogs 回看最近写入/事务执行结果,必要时再调用 inspect_recent_sql_activity 下钻'); + } + + return { + commitPolicy: { + commitMode, + autoCommitDelayMs, + transactionAlwaysOnForDML: true, + semantics: 'SQL 编辑器执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时始终先进入托管事务;“手动/自动”只控制执行成功后的 COMMIT 时机,不控制是否开启事务。', + }, + activeSqlTab, + pendingTransactionCount: pendingTransactions.length, + activePendingTransaction, + pendingTransactions, + recentRelevantLogs, + warnings, + nextActions, + }; +}; diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 148d06d..8541bb9 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_app_health', '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_mcp_draft', 'inspect_mcp_tool_schema', '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_ai_message_flow', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', '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_mcp_draft', 'inspect_mcp_tool_schema', '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_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], skills, userPromptSettings, }); @@ -93,6 +93,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_editor_transaction 读取真实提交设置'); expect(joined).toContain('inspect_sql_risk'); expect(joined).toContain('inspect_recent_connection_failures 读取真实连接失败总结'); expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部'); diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index dd9f8e2..7e510b7 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -176,6 +176,12 @@ export const appendDatabaseInspectionGuidanceMessages = ( 'inspect_recent_sql_activity', '如果用户提到“最近都执行了什么”“是不是刚删过数据”“最近主要在查还是在改”“哪个库最近报错最多”,优先调用 inspect_recent_sql_activity 先读最近 SQL 活动总结,再决定是否继续下钻 inspect_recent_sql_logs 看具体语句。', ); + appendGuidanceIfToolAvailable( + messages, + availableToolNames, + 'inspect_sql_editor_transaction', + '如果用户提到“SQL 编辑器手动提交/自动提交”“当前有没有未提交事务”“执行 update/insert/delete 会不会自动提交”“事务语义是不是理解错了”,优先调用 inspect_sql_editor_transaction 读取真实提交设置、待提交事务和当前 SQL 页签是否会进入托管事务,不要凭记忆解释。', + ); appendGuidanceIfToolAvailable( messages, availableToolNames, diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 8c516ed..917c64c 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -53,6 +53,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_workspace_tabs: '盘点当前工作区页签', inspect_recent_sql_logs: '回看最近 SQL 执行日志', inspect_recent_sql_activity: '总结最近 SQL 活动', + inspect_sql_editor_transaction: '读取 SQL 编辑器事务状态', inspect_app_logs: '回看 GoNavi 应用日志', inspect_recent_connection_failures: '总结最近连接失败记录', inspect_ai_last_render_error: '读取最近一次 AI 渲染异常', diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 06426fa..61f2967 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1124,6 +1124,15 @@ export interface SqlEditorTransactionOptions { autoCommitDelayMs: number; } +export interface SqlEditorPendingTransactionState { + id: string; + tabId: string; + commitMode: "manual" | "auto"; + autoCommitDelayMs: number; + createdAt: number; + autoCommitDueAt?: number | null; +} + interface AppState { connections: SavedConnection[]; connectionTags: ConnectionTag[]; @@ -1143,6 +1152,7 @@ interface AppState { queryOptions: QueryOptions; dataEditTransactionOptions: DataEditTransactionOptions; sqlEditorTransactionOptions: SqlEditorTransactionOptions; + sqlEditorPendingTransactions: Record; shortcutOptions: ShortcutOptions; sqlSnippets: SqlSnippet[]; sqlLogs: SqlLog[]; @@ -1254,6 +1264,10 @@ interface AppState { setSqlEditorTransactionOptions: ( options: Partial, ) => void; + setSqlEditorPendingTransaction: ( + tabId: string, + transaction: Omit | null, + ) => void; updateShortcut: ( action: ShortcutAction, binding: Partial, @@ -2051,6 +2065,7 @@ export const useStore = create()( commitMode: "manual", autoCommitDelayMs: 5000, }, + sqlEditorPendingTransactions: {}, shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS), sqlSnippets: DEFAULT_SQL_SNIPPETS, sqlLogs: [], @@ -2809,6 +2824,23 @@ export const useStore = create()( ...options, }), })), + setSqlEditorPendingTransaction: (tabId, transaction) => + set((state) => { + const safeTabId = String(tabId || "").trim(); + if (!safeTabId) { + return {}; + } + const next = { ...state.sqlEditorPendingTransactions }; + if (!transaction) { + delete next[safeTabId]; + return { sqlEditorPendingTransactions: next }; + } + next[safeTabId] = { + ...transaction, + tabId: safeTabId, + }; + return { sqlEditorPendingTransactions: next }; + }), updateShortcut: (action, binding, platform) => { runWithExplicitShortcutPersistence(() => { const targetPlatform = platform ?? getShortcutPlatform(); diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 3356689..3bcd8f6 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -463,6 +463,28 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_sql_editor_transaction", + icon: "🔁", + desc: "查看 SQL 编辑器事务提交状态", + detail: + "返回 SQL 编辑器 DML 托管事务语义、当前手动/自动提交设置、活动 SQL 页签是否会进入托管事务、待提交事务以及最近写入/事务执行记录。适合用户问“手动/自动提交到底是什么意思”“当前有没有事务没提交”“执行 update/insert/delete 会不会自动提交”时先读真实状态。", + params: "includeSqlPreview?(默认 true)", + tool: { + type: "function", + function: { + name: "inspect_sql_editor_transaction", + description: + "读取 SQL 编辑器事务状态快照,包括 DML 始终进入托管事务的真实语义、当前提交模式、自动提交延迟、活动 SQL 页签是否会触发托管事务、待提交事务列表和最近写入/事务日志。适用于用户提到 SQL 编辑器手动提交、自动提交、未提交事务、DML 执行后是否提交或事务语义不清时,先读取真实状态再解释。", + parameters: { + type: "object", + properties: { + includeSqlPreview: { type: "boolean", description: "可选,是否返回活动 SQL 页签的 SQL 预览,默认 true" }, + }, + }, + }, + }, + }, { name: "inspect_sql_risk", icon: "🛑", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index 8353278..70e2354 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -147,6 +147,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 sqlEditorTransactionTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_editor_transaction'); 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'); @@ -158,6 +159,8 @@ describe('aiToolRegistry', () => { expect(recentActivityTool?.desc).toContain('最近 SQL 活动'); expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动'); + expect(sqlEditorTransactionTool?.desc).toContain('SQL 编辑器事务'); + expect(sqlEditorTransactionTool?.tool.function.description).toContain('托管事务'); expect(sqlRiskTool?.desc).toContain('SQL 的执行风险'); expect(sqlRiskTool?.tool.function.description).toContain('危险点'); expect(appLogTool?.desc).toContain('GoNavi 应用日志'); @@ -208,6 +211,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_editor_transaction')).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);