feat(ai-tools): 新增工作区页签探针并拆分界面洞察模块

This commit is contained in:
Syngnat
2026-06-08 17:05:29 +08:00
parent 5b8bbd672e
commit 2c95009d1f
9 changed files with 395 additions and 44 deletions

View File

@@ -30,6 +30,10 @@ describe('AIChatPanel message render isolation', () => {
it('teaches the runtime to use deeper schema tools when analyzing structure details', () => {
expect(source).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
expect(source).toContain('inspect_active_tab 读取当前活动页签上下文');
expect(source).toContain('inspect_workspace_tabs 盘点当前工作区');
expect(source).toContain('tabs: useStore.getState().tabs');
expect(source).toContain('activeTabId: useStore.getState().activeTabId');
expect(source).toContain('toolContextMap: toolContextMapRef.current');
expect(source).toContain('buildToolResultMessage');
});

View File

@@ -1209,12 +1209,14 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
6. 如果是常规问答(不涉及数据库查询)则正常作答即可。
SQL 生成规则(极重要,必须严格遵守):
7. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel不得写成 pay_channel
8. 如果用户在问索引优化、联表关系、触发器副作用、约束或 DDL 细节,在 get_columns 之后继续按需调用 get_indexes、get_foreign_keys、get_triggers、get_table_ddl再给结论
9. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身
10. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭
11. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库
12. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
7. 如果用户提到“当前页签”“当前 SQL”“当前编辑器”“这条语句”但消息里没有贴出具体内容优先调用 inspect_active_tab 读取当前活动页签上下文,不要猜测当前工作区里打开的内容
8. 如果用户提到“当前开了哪些页签”“工作区里有哪些 tab”“我现在打开了哪些查询”优先调用 inspect_workspace_tabs 盘点当前工作区,再决定深入哪个页签
9. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel不得写成 pay_channel
10. 如果用户在问索引优化、联表关系、触发器副作用、约束或 DDL 细节,在 get_columns 之后继续按需调用 get_indexes、get_foreign_keys、get_triggers、get_table_ddl再给结论
11. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身
12. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭。
13. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。
14. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
\`\`\`sql
-- @context connectionId=1770778676549 dbName=mkefu_test
SELECT * FROM users WHERE status = 1;
@@ -1262,6 +1264,8 @@ SELECT * FROM users WHERE status = 1;
const execution = await executeLocalAIToolCall({
toolCall: tc,
connections: currentConnections,
tabs: useStore.getState().tabs,
activeTabId: useStore.getState().activeTabId,
mcpTools,
toolContextMap: toolContextMapRef.current,
sqlLogs: useStore.getState().sqlLogs,

View File

@@ -6,7 +6,7 @@ import AIBuiltinToolsCatalog from './AIBuiltinToolsCatalog';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIBuiltinToolsCatalog', () => {
it('renders the field-to-table flow, sql log replay flow, and both snapshot tools', () => {
it('renders the workspace-tab flow, active-tab flow, sql log replay flow, and both snapshot tools', () => {
const markup = renderToStaticMarkup(
<AIBuiltinToolsCatalog
darkMode={false}
@@ -26,6 +26,10 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_table_bundle');
expect(markup).toContain('全库快速摸底');
expect(markup).toContain('inspect_database_bundle');
expect(markup).toContain('读取当前页签');
expect(markup).toContain('inspect_active_tab');
expect(markup).toContain('盘点当前工作区');
expect(markup).toContain('inspect_workspace_tabs');
expect(markup).toContain('回看最近执行记录');
expect(markup).toContain('inspect_recent_sql_logs');
expect(markup).toContain('理解样例数据');

View File

@@ -37,6 +37,16 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_database_bundle → inspect_table_bundle',
description: '适合先看整库有哪些表、每张表大概有哪些字段,再对目标表继续做深挖快照。',
},
{
title: '读取当前页签',
steps: 'inspect_active_tab → get_columns / get_indexes / execute_sql',
description: '适合先读取当前编辑器里的 SQL 草稿或当前表页签,再继续做字段核对、索引分析和只读验证。',
},
{
title: '盘点当前工作区',
steps: 'inspect_workspace_tabs → inspect_active_tab → get_columns / execute_sql',
description: '适合先看当前打开了哪些 SQL / 表 / 命令页签,再切到目标页签继续做字段核对、对比分析和只读验证。',
},
{
title: '回看最近执行记录',
steps: 'inspect_recent_sql_logs → get_columns / get_indexes / execute_sql',

View File

@@ -49,6 +49,90 @@ describe('aiLocalToolExecutor', () => {
});
});
it('returns the current active tab snapshot so the model can inspect the editor draft directly', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_active_tab', {
includeContent: true,
}),
connections: [buildConnection()],
tabs: [{
id: 'tab-query-1',
title: '订单查询',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'SELECT id, status FROM orders WHERE status = \'paid\'',
filePath: 'D:/sql/orders.sql',
}],
activeTabId: 'tab-query-1',
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"hasActiveTab":true');
expect(result.content).toContain('"type":"query"');
expect(result.content).toContain('"connectionName":"主库"');
expect(result.content).toContain('"contentKind":"sql"');
expect(result.content).toContain('SELECT id, status FROM orders');
});
it('returns a workspace tab overview so the model can inspect which editors are currently open', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_workspace_tabs', {
limit: 2,
includeContent: true,
}),
connections: [buildConnection()],
tabs: [
{
id: 'tab-query-1',
title: '订单查询',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'SELECT * FROM orders WHERE status = \'paid\'',
},
{
id: 'tab-table-1',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'crm',
tableName: 'users',
},
{
id: 'tab-redis-1',
title: '缓存命令',
type: 'redis-command',
connectionId: 'conn-1',
query: 'GET order:1',
redisDB: 2,
},
],
activeTabId: 'tab-query-1',
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"activeTabId":"tab-query-1"');
expect(result.content).toContain('"totalTabs":3');
expect(result.content).toContain('"returnedTabs":2');
expect(result.content).toContain('"truncated":true');
expect(result.content).toContain('"isActive":true');
expect(result.content).toContain('"title":"订单查询"');
expect(result.content).toContain('SELECT * FROM orders');
});
it('blocks execute_sql when the AI safety check rejects the statement', async () => {
const query = vi.fn();
const result = await executeLocalAIToolCall({

View File

@@ -1,11 +1,16 @@
import { DBGetAllColumns, DBGetDatabases, DBGetTables } from '../../../wailsjs/go/app/App';
import type { SqlLog } from '../../store';
import type { AIChatMessage, AIMCPToolDescriptor, AIToolCall, SavedConnection } from '../../types';
import type { AIChatMessage, AIMCPToolDescriptor, AIToolCall, SavedConnection, TabData } from '../../types';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
import { buildPaginatedSelectSQL, quoteQualifiedIdent } from '../../utils/sql';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import {
buildActiveTabSnapshot,
buildRecentSqlLogsSnapshot,
buildWorkspaceTabsSnapshot,
} from './aiWorkspaceInsights';
export interface AIToolContextEntry {
connectionId: string;
@@ -30,6 +35,8 @@ interface AILocalToolRuntime {
export interface ExecuteLocalAIToolCallOptions {
toolCall: AIToolCall;
connections: SavedConnection[];
tabs?: TabData[];
activeTabId?: string | null;
mcpTools: AIMCPToolDescriptor[];
toolContextMap: Map<string, AIToolContextEntry>;
sqlLogs?: SqlLog[];
@@ -139,21 +146,6 @@ const normalizePerTableColumnLimit = (input: unknown): number => {
return value;
};
const normalizeRecentSqlLogLimit = (input: unknown): number => {
const value = Math.floor(Number(input) || 20);
if (value < 1) return 1;
if (value > 100) return 100;
return value;
};
const normalizeSqlLogStatus = (input: unknown): 'all' | 'success' | 'error' => {
const value = String(input || 'all').trim().toLowerCase();
if (value === 'success' || value === 'error') {
return value;
}
return 'all';
};
const buildPreviewSQLForTable = (connection: SavedConnection, tableName: string, limit: number): string => {
const dbType = String(connection.config?.type || '').trim();
return buildPaginatedSelectSQL(
@@ -168,6 +160,8 @@ const buildPreviewSQLForTable = (connection: SavedConnection, tableName: string,
export async function executeLocalAIToolCall({
toolCall,
connections,
tabs = [],
activeTabId = null,
mcpTools,
toolContextMap,
sqlLogs = [],
@@ -181,6 +175,35 @@ export async function executeLocalAIToolCall({
try {
const args = JSON.parse(toolCall.function.arguments || '{}');
switch (toolCall.function.name) {
case 'inspect_active_tab': {
try {
content = JSON.stringify(buildActiveTabSnapshot({
tabs,
activeTabId,
connections,
includeContent: args.includeContent !== false,
}));
success = true;
} catch (error: any) {
content = `读取当前活动页签失败: ${error?.message || error}`;
}
break;
}
case 'inspect_workspace_tabs': {
try {
content = JSON.stringify(buildWorkspaceTabsSnapshot({
tabs,
activeTabId,
connections,
includeContent: args.includeContent === true,
limit: args.limit,
}));
success = true;
} catch (error: any) {
content = `读取当前工作区页签失败: ${error?.message || error}`;
}
break;
}
case 'get_connections': {
const availableConnections = connections.map((connection) => ({
id: connection.id,
@@ -576,28 +599,11 @@ export async function executeLocalAIToolCall({
}
case 'inspect_recent_sql_logs': {
try {
const status = normalizeSqlLogStatus(args.status);
const limit = normalizeRecentSqlLogLimit(args.limit);
const filteredLogs = sqlLogs.filter((log) => status === 'all' || log.status === status);
const visibleLogs = filteredLogs.slice(0, limit).map((log) => ({
id: log.id,
timestamp: log.timestamp,
status: log.status,
duration: log.duration,
dbName: log.dbName || '',
affectedRows: typeof log.affectedRows === 'number' ? log.affectedRows : null,
sql: log.sql,
message: log.message || '',
content = JSON.stringify(buildRecentSqlLogsSnapshot({
sqlLogs,
limit: args.limit,
status: args.status,
}));
content = JSON.stringify({
status,
limit,
totalMatched: filteredLogs.length,
successCount: filteredLogs.filter((log) => log.status === 'success').length,
errorCount: filteredLogs.filter((log) => log.status === 'error').length,
logs: visibleLogs,
});
success = true;
} catch (error: any) {
content = `获取最近 SQL 日志失败: ${error?.message || error}`;

View File

@@ -0,0 +1,192 @@
import type { SqlLog } from '../../store';
import type { SavedConnection, TabData } from '../../types';
const ACTIVE_TAB_CONTENT_LIMIT = 12000;
const WORKSPACE_TAB_CONTENT_LIMIT = 4000;
const normalizeWorkspaceTabLimit = (input: unknown): number => {
const value = Math.floor(Number(input) || 12);
if (value < 1) return 1;
if (value > 30) return 30;
return value;
};
const normalizeRecentSqlLogLimit = (input: unknown): number => {
const value = Math.floor(Number(input) || 20);
if (value < 1) return 1;
if (value > 100) return 100;
return value;
};
const normalizeSqlLogStatus = (input: unknown): 'all' | 'success' | 'error' => {
const value = String(input || 'all').trim().toLowerCase();
if (value === 'success' || value === 'error') {
return value;
}
return 'all';
};
const resolveContentKind = (tab: TabData, includeContent: boolean, trimmedContent: string): 'sql' | 'command' | 'text' | 'none' => {
if (!includeContent || !trimmedContent) {
return 'none';
}
if (tab.type === 'query') {
return 'sql';
}
if (tab.type === 'redis-command') {
return 'command';
}
return 'text';
};
const buildTabSnapshot = (params: {
tab: TabData;
activeTabId?: string | null;
connections: SavedConnection[];
includeContent: boolean;
contentLimit: number;
}) => {
const { tab, activeTabId = null, connections, includeContent, contentLimit } = params;
const activeConnection = connections.find((connection) => connection.id === tab.connectionId);
const rawContent =
tab.type === 'query' || tab.type === 'redis-command'
? String(tab.query || '')
: '';
const trimmedContent = rawContent.trim();
const visibleContent = includeContent ? trimmedContent.slice(0, contentLimit) : '';
return {
id: tab.id,
isActive: tab.id === activeTabId,
title: tab.title,
type: tab.type,
connectionId: tab.connectionId,
connectionName: activeConnection?.name || '',
connectionType: activeConnection?.config?.type || '',
dbName: tab.dbName || '',
tableName: tab.tableName || '',
filePath: tab.filePath || '',
readOnly: tab.readOnly === true,
queryMode: tab.queryMode || '',
providerMode: tab.providerMode || '',
resourcePath: tab.resourcePath || '',
resourceKind: tab.resourceKind || '',
redisDB: typeof tab.redisDB === 'number' ? tab.redisDB : null,
schemaName: tab.schemaName || '',
viewName: tab.viewName || '',
viewKind: tab.viewKind || '',
triggerName: tab.triggerName || '',
eventName: tab.eventName || '',
routineName: tab.routineName || '',
routineType: tab.routineType || '',
contentKind: resolveContentKind(tab, includeContent, trimmedContent),
content: visibleContent,
contentCharCount: trimmedContent.length,
contentTruncated: includeContent && trimmedContent.length > visibleContent.length,
};
};
export const buildActiveTabSnapshot = (params: {
tabs?: TabData[];
activeTabId?: string | null;
connections: SavedConnection[];
includeContent?: boolean;
}) => {
const {
tabs = [],
activeTabId = null,
connections,
includeContent = true,
} = params;
const activeTab = tabs.find((tab) => tab.id === activeTabId);
if (!activeTab) {
return {
hasActiveTab: false,
message: '当前没有活动页签',
};
}
return {
hasActiveTab: true,
tabId: activeTab.id,
...buildTabSnapshot({
tab: activeTab,
activeTabId,
connections,
includeContent,
contentLimit: ACTIVE_TAB_CONTENT_LIMIT,
}),
};
};
export const buildWorkspaceTabsSnapshot = (params: {
tabs?: TabData[];
activeTabId?: string | null;
connections: SavedConnection[];
includeContent?: boolean;
limit?: unknown;
}) => {
const {
tabs = [],
activeTabId = null,
connections,
includeContent = false,
limit,
} = params;
const safeLimit = normalizeWorkspaceTabLimit(limit);
const orderedTabs = [...tabs].sort((left, right) => {
if (left.id === activeTabId && right.id !== activeTabId) {
return -1;
}
if (right.id === activeTabId && left.id !== activeTabId) {
return 1;
}
return 0;
});
const visibleTabs = orderedTabs.slice(0, safeLimit);
return {
activeTabId,
totalTabs: orderedTabs.length,
returnedTabs: visibleTabs.length,
truncated: orderedTabs.length > visibleTabs.length,
tabs: visibleTabs.map((tab) =>
buildTabSnapshot({
tab,
activeTabId,
connections,
includeContent,
contentLimit: WORKSPACE_TAB_CONTENT_LIMIT,
})),
};
};
export const buildRecentSqlLogsSnapshot = (params: {
sqlLogs?: SqlLog[];
limit?: unknown;
status?: unknown;
}) => {
const { sqlLogs = [], limit, status } = params;
const safeStatus = normalizeSqlLogStatus(status);
const safeLimit = normalizeRecentSqlLogLimit(limit);
const filteredLogs = sqlLogs.filter((log) => safeStatus === 'all' || log.status === safeStatus);
const visibleLogs = filteredLogs.slice(0, safeLimit).map((log) => ({
id: log.id,
timestamp: log.timestamp,
status: log.status,
duration: log.duration,
dbName: log.dbName || '',
affectedRows: typeof log.affectedRows === 'number' ? log.affectedRows : null,
sql: log.sql,
message: log.message || '',
}));
return {
status: safeStatus,
limit: safeLimit,
totalMatched: filteredLogs.length,
successCount: filteredLogs.filter((log) => log.status === 'success').length,
errorCount: filteredLogs.filter((log) => log.status === 'error').length,
logs: visibleLogs,
};
};

View File

@@ -34,6 +34,8 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
get_table_ddl: '提取建表语句',
inspect_table_bundle: '抓取完整表结构快照',
inspect_database_bundle: '抓取数据库结构总览',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',
inspect_recent_sql_logs: '回看最近 SQL 执行日志',
preview_table_rows: '预览真实样例数据',
execute_sql: '执行只读 SQL 验证',

View File

@@ -309,6 +309,51 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_active_tab",
icon: "📍",
desc: "查看当前活动页签上下文",
detail:
"返回当前活动页签的类型、连接、数据库、表名,以及当前 SQL / 命令页签里的草稿内容(超长会截断)。适合用户说“看我当前这条 SQL”“优化这个编辑器里的语句”时先让 AI 直接读取当前工作区上下文。",
params: "includeContent?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_active_tab",
description:
"获取当前活动页签的上下文快照,包括页签类型、连接、数据库、表名,以及当前 SQL / 命令页签里的草稿内容。适用于用户提到当前页签、当前 SQL、当前编辑器、这条语句时先读取真实界面上下文避免让模型猜测。",
parameters: {
type: "object",
properties: {
includeContent: { type: "boolean", description: "可选,是否附带页签中的 SQL / 命令草稿内容,默认 true" },
},
},
},
},
},
{
name: "inspect_workspace_tabs",
icon: "🗃️",
desc: "查看当前工作区打开的页签总览",
detail:
"返回当前工作区里打开的页签列表、哪个是活动页签,以及每个页签对应的连接、数据库、表名等上下文。适合用户说“我现在开了哪些 SQL”“看看我工作区里有哪些页签”“帮我对比这几个查询页签”时先读取真实工作区布局再继续分析。",
params: "limit?(默认 12), includeContent?(默认 false)",
tool: {
type: "function",
function: {
name: "inspect_workspace_tabs",
description:
"获取当前工作区已打开页签的总览,包括活动页签、页签类型、连接、数据库、表名,以及可选的 SQL / 命令草稿内容。适用于用户提到当前工作区、打开了哪些页签、哪几个查询页签、想对比多个编辑器内容时,先读取真实界面状态,避免模型猜测。",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,最多返回多少个页签,默认 12最大 30" },
includeContent: { type: "boolean", description: "可选,是否附带页签中的 SQL / 命令草稿内容,默认 false" },
},
},
},
},
},
{
name: "inspect_recent_sql_logs",
icon: "🧾",