mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 10:29:52 +08:00
✨ feat(ai): 新增 SQL 编辑器事务状态探针
- 新增 inspect_sql_editor_transaction 内置探针,返回提交模式、待提交事务和当前 SQL 页签事务语义 - 将 SQL 编辑器待提交事务状态登记到 store,供 AI 只读诊断使用 - 增加 /tx 斜杠菜单、工具目录、系统引导和回归测试
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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 条件');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => [
|
||||
|
||||
@@ -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: '汇总最近连接失败记录失败',
|
||||
|
||||
@@ -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<string, AISqlEditorPendingTransactionRuntimeState>;
|
||||
}
|
||||
|
||||
export interface AISnapshotInspectionRuntime {
|
||||
getAIRuntimeState?: () => Promise<AISnapshotInspectionRuntimeState | undefined>;
|
||||
getMCPServers?: () => Promise<AIMCPServerConfig[] | undefined>;
|
||||
getMCPClientInstallStatuses?: () => Promise<AIMCPClientInstallStatus[] | undefined>;
|
||||
getShortcutOptions?: () => Promise<ShortcutOptions | undefined>;
|
||||
getShortcutPlatform?: () => Promise<ShortcutPlatform | undefined>;
|
||||
getSqlEditorTransactionState?: () => Promise<AISqlEditorTransactionRuntimeState | undefined>;
|
||||
readAppLogTail?: (lineLimit: number, keyword: string) => Promise<any>;
|
||||
readSQLFile?: (filePath: string) => Promise<any>;
|
||||
checkSQL?: (sql: string) => Promise<{ allowed?: boolean; operationType?: string } | undefined>;
|
||||
|
||||
235
frontend/src/components/ai/aiSqlEditorTransactionInsights.ts
Normal file
235
frontend/src/components/ai/aiSqlEditorTransactionInsights.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 读取真实应用日志尾部');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
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 渲染异常',
|
||||
|
||||
@@ -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<string, SqlEditorPendingTransactionState>;
|
||||
shortcutOptions: ShortcutOptions;
|
||||
sqlSnippets: SqlSnippet[];
|
||||
sqlLogs: SqlLog[];
|
||||
@@ -1254,6 +1264,10 @@ interface AppState {
|
||||
setSqlEditorTransactionOptions: (
|
||||
options: Partial<SqlEditorTransactionOptions>,
|
||||
) => void;
|
||||
setSqlEditorPendingTransaction: (
|
||||
tabId: string,
|
||||
transaction: Omit<SqlEditorPendingTransactionState, "tabId"> | null,
|
||||
) => void;
|
||||
updateShortcut: (
|
||||
action: ShortcutAction,
|
||||
binding: Partial<ShortcutPlatformBinding>,
|
||||
@@ -2051,6 +2065,7 @@ export const useStore = create<AppState>()(
|
||||
commitMode: "manual",
|
||||
autoCommitDelayMs: 5000,
|
||||
},
|
||||
sqlEditorPendingTransactions: {},
|
||||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||||
sqlSnippets: DEFAULT_SQL_SNIPPETS,
|
||||
sqlLogs: [],
|
||||
@@ -2809,6 +2824,23 @@ export const useStore = create<AppState>()(
|
||||
...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();
|
||||
|
||||
@@ -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: "🛑",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user