diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 40e2086..fad542c 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -1056,6 +1056,8 @@ export const AIChatPanel: React.FC = ({ const execution = await executeLocalAIToolCall({ toolCall: tc, connections: currentConnections, + activeContext: useStore.getState().activeContext, + aiContexts: useStore.getState().aiContexts, tabs: useStore.getState().tabs, activeTabId: useStore.getState().activeTabId, mcpTools, diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 26a958c..c9e5e64 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -6,7 +6,7 @@ import AIBuiltinToolsCatalog from './AIBuiltinToolsCatalog'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; describe('AIBuiltinToolsCatalog', () => { - it('renders the workspace-tab flow, active-tab flow, sql log replay flow, and both snapshot tools', () => { + it('renders the AI-context flow, workspace-tab flow, sql log replay flow, and both snapshot tools', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('inspect_table_bundle'); expect(markup).toContain('全库快速摸底'); expect(markup).toContain('inspect_database_bundle'); + expect(markup).toContain('查看当前 AI 上下文'); + expect(markup).toContain('inspect_ai_context'); expect(markup).toContain('读取当前页签'); expect(markup).toContain('inspect_active_tab'); expect(markup).toContain('盘点当前工作区'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 0ee1005..ef35726 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -37,6 +37,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_database_bundle → inspect_table_bundle', description: '适合先看整库有哪些表、每张表大概有哪些字段,再对目标表继续做深挖快照。', }, + { + title: '查看当前 AI 上下文', + steps: 'inspect_ai_context → inspect_table_bundle / get_columns', + description: '适合先确认这轮对话当前到底挂了哪些表结构,再继续做字段核对、表设计评审或 SQL 生成。', + }, { title: '读取当前页签', steps: 'inspect_active_tab → get_columns / get_indexes / execute_sql', diff --git a/frontend/src/components/ai/AIChatAttachmentStrip.tsx b/frontend/src/components/ai/AIChatAttachmentStrip.tsx new file mode 100644 index 0000000..728b259 --- /dev/null +++ b/frontend/src/components/ai/AIChatAttachmentStrip.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +interface AIChatAttachmentStripProps { + draftImages: string[]; + onRemove: (index: number) => void; + overlayTheme: OverlayWorkbenchTheme; + variant: 'legacy' | 'v2'; +} + +export const AIChatAttachmentStrip: React.FC = ({ + draftImages, + onRemove, + overlayTheme, + variant, +}) => { + if (draftImages.length === 0) { + return null; + } + + if (variant === 'v2') { + return ( +
+ {draftImages.map((b64, index) => ( +
+ {`Draft + +
+ ))} +
+ ); + } + + return ( + <> + {draftImages.map((b64, index) => ( +
+ {`Draft +
onRemove(index)} + style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }} + > + ✕ +
+
+ ))} + + ); +}; + +export default AIChatAttachmentStrip; diff --git a/frontend/src/components/ai/AIChatComposerNotice.tsx b/frontend/src/components/ai/AIChatComposerNotice.tsx new file mode 100644 index 0000000..8d483c4 --- /dev/null +++ b/frontend/src/components/ai/AIChatComposerNotice.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Button } from 'antd'; +import { ExclamationCircleFilled } from '@ant-design/icons'; + +import type { AIComposerNotice } from '../../utils/aiComposerNotice'; + +interface AIChatComposerNoticeProps { + composerNotice?: AIComposerNotice | null; + darkMode: boolean; + textColor: string; + mutedColor: string; + onComposerNoticeAction?: () => void; +} + +const resolveNoticePalette = (tone: AIComposerNotice['tone'] | undefined, darkMode: boolean) => { + if (tone === 'error') { + return darkMode + ? { + background: 'rgba(255,120,117,0.12)', + borderColor: 'rgba(255,120,117,0.24)', + iconColor: '#ff7875', + } + : { + background: 'rgba(255,77,79,0.08)', + borderColor: 'rgba(255,77,79,0.16)', + iconColor: '#ff4d4f', + }; + } + + return darkMode + ? { + background: 'rgba(250,173,20,0.12)', + borderColor: 'rgba(250,173,20,0.22)', + iconColor: '#ffd666', + } + : { + background: 'rgba(250,173,20,0.08)', + borderColor: 'rgba(250,173,20,0.18)', + iconColor: '#d48806', + }; +}; + +export const AIChatComposerNotice: React.FC = ({ + composerNotice, + darkMode, + textColor, + mutedColor, + onComposerNoticeAction, +}) => { + if (!composerNotice) { + return null; + } + + const palette = resolveNoticePalette(composerNotice.tone, darkMode); + const actionLabel = composerNotice.action?.label; + + return ( +
+ +
+
+ {composerNotice.title} +
+
+ {composerNotice.description} +
+ {actionLabel && typeof onComposerNoticeAction === 'function' && ( + + )} +
+
+ ); +}; + +export default AIChatComposerNotice; diff --git a/frontend/src/components/ai/AIChatContextPreview.tsx b/frontend/src/components/ai/AIChatContextPreview.tsx new file mode 100644 index 0000000..235b4bb --- /dev/null +++ b/frontend/src/components/ai/AIChatContextPreview.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Tag } from 'antd'; +import { DatabaseOutlined, DownOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons'; + +import type { AIContextItem } from '../../types'; + +interface AIChatContextPreviewProps { + variant: 'legacy' | 'v2'; + activeContextItems: AIContextItem[]; + contextExpanded: boolean; + darkMode: boolean; + textColor: string; + onToggleExpanded: () => void; + onOpenContext: () => void; + onRemoveContext: (dbName: string, tableName: string) => void; +} + +const renderContextTableChips = ( + activeContextItems: AIContextItem[], + onRemoveContext: (dbName: string, tableName: string) => void, + className?: string, + style?: React.CSSProperties, +) => activeContextItems.map((ctx, idx) => ( + { + event.preventDefault(); + onRemoveContext(ctx.dbName, ctx.tableName); + }} + className={className} + style={style} + > + + {ctx.tableName} + +)); + +export const AIChatContextPreview: React.FC = ({ + variant, + activeContextItems, + contextExpanded, + darkMode, + textColor, + onToggleExpanded, + onOpenContext, + onRemoveContext, +}) => { + if (variant === 'v2') { + return ( + <> +
+ + +
+ + {contextExpanded && activeContextItems.length > 0 && ( +
+
当前上下文 · {activeContextItems.length}
+ {renderContextTableChips(activeContextItems, onRemoveContext, 'gn-v2-ai-context-table-chip', { margin: 0 })} +
+ )} + + ); + } + + if (activeContextItems.length === 0) { + return null; + } + + return ( + <> + + + 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'} + + + {contextExpanded && renderContextTableChips( + activeContextItems, + onRemoveContext, + undefined, + { background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }, + )} + + ); +}; + +export default AIChatContextPreview; diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index 5611d25..cfa1784 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Input, Select, Tooltip, message, Button, Tag } from 'antd'; -import { CodeOutlined, DatabaseOutlined, DownOutlined, PlusOutlined, SendOutlined, StopOutlined, TableOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons'; +import { Input, Select, Tooltip, message, Button } from 'antd'; +import { CodeOutlined, DatabaseOutlined, DownOutlined, SendOutlined, StopOutlined, TableOutlined, PictureOutlined } from '@ant-design/icons'; import { useStore } from '../../store'; import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; @@ -11,6 +11,10 @@ import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut'; import type { ShortcutPlatform, ShortcutPlatformBinding } from '../../utils/shortcuts'; import AIContextSelectorModal from './AIContextSelectorModal'; import AISlashCommandMenu, { type AISlashCommandDefinition } from './AISlashCommandMenu'; +import AIChatComposerNotice from './AIChatComposerNotice'; +import AIChatAttachmentStrip from './AIChatAttachmentStrip'; +import AIChatContextPreview from './AIChatContextPreview'; +import { filterAISlashCommands } from './aiSlashCommands'; interface AIChatInputProps { input: string; @@ -57,17 +61,21 @@ export const AIChatInput: React.FC = ({ const [appendingContext, setAppendingContext] = React.useState(false); const fileInputRef = React.useRef(null); + const appendDraftImage = React.useCallback((blob: Blob) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + setDraftImages(prev => [...prev, event.target!.result as string]); + } + }; + reader.readAsDataURL(blob); + }, [setDraftImages]); + const handleImageUpload = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); files.forEach(file => { if (file.type.indexOf('image') !== -1) { - const reader = new FileReader(); - reader.onload = (event) => { - if (event.target?.result) { - setDraftImages(prev => [...prev, event.target!.result as string]); - } - }; - reader.readAsDataURL(file); + appendDraftImage(file); } }); if (fileInputRef.current) { @@ -80,49 +88,11 @@ export const AIChatInput: React.FC = ({ const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase())); const [contextExpanded, setContextExpanded] = React.useState(false); - const composerNoticePalette = React.useMemo(() => { - if (composerNotice?.tone === 'error') { - return darkMode - ? { - background: 'rgba(255,120,117,0.12)', - borderColor: 'rgba(255,120,117,0.24)', - iconColor: '#ff7875', - } - : { - background: 'rgba(255,77,79,0.08)', - borderColor: 'rgba(255,77,79,0.16)', - iconColor: '#ff4d4f', - }; - } - - return darkMode - ? { - background: 'rgba(250,173,20,0.12)', - borderColor: 'rgba(250,173,20,0.22)', - iconColor: '#ffd666', - } - : { - background: 'rgba(250,173,20,0.08)', - borderColor: 'rgba(250,173,20,0.18)', - iconColor: '#d48806', - }; - }, [composerNotice, darkMode]); - const composerNoticeActionLabel = composerNotice?.action?.label; // Slash commands const [showSlashMenu, setShowSlashMenu] = React.useState(false); const [slashFilter, setSlashFilter] = React.useState(''); - const slashCommands = React.useMemo(() => [ - { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, - { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, - { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' }, - { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' }, - { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' }, - { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' }, - { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' }, - { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' }, - ], []); - const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase())); + const filteredSlashCmds = React.useMemo(() => filterAISlashCommands(slashFilter), [slashFilter]); const aiContexts = useStore(state => state.aiContexts); const addAIContext = useStore(state => state.addAIContext); @@ -259,17 +229,11 @@ export const AIChatInput: React.FC = ({ event.preventDefault(); const blob = items[i].getAsFile(); if (blob) { - const reader = new FileReader(); - reader.onload = (loadEvent) => { - if (loadEvent.target?.result) { - setDraftImages(prev => [...prev, loadEvent.target!.result as string]); - } - }; - reader.readAsDataURL(blob); + appendDraftImage(blob); } } } - }, [setDraftImages]); + }, [appendDraftImage]); const handleComposerInputChange = React.useCallback((value: string) => { setInput(value); @@ -296,6 +260,14 @@ export const AIChatInput: React.FC = ({ textareaRef.current?.focus(); }, [setInput, textareaRef]); + const handleRemoveDraftImage = React.useCallback((index: number) => { + setDraftImages(prev => prev.filter((_, currentIndex) => currentIndex !== index)); + }, [setDraftImages]); + + const handleRemoveContextItem = React.useCallback((dbName: string, tableName: string) => { + removeAIContext(connectionKey, dbName, tableName); + }, [connectionKey, removeAIContext]); + if (!isV2Ui) { return (
@@ -309,73 +281,30 @@ export const AIChatInput: React.FC = ({ padding: '8px 4px 8px' }}>
- {activeContextItems.length > 0 && ( - setContextExpanded(!contextExpanded)} - style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }} - > - - 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'} - - - )} - - {contextExpanded && activeContextItems.map((ctx, idx) => ( - { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }} - style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }} - > - 🗄️ {ctx.tableName} - - ))} - {draftImages.map((b64, i) => ( -
- {`Draft -
setDraftImages(prev => prev.filter((_, idx) => idx !== i))} - style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }} - > - ✕ -
-
- ))} + setContextExpanded(!contextExpanded)} + onOpenContext={handleOpenContext} + onRemoveContext={handleRemoveContextItem} + /> +
- {composerNotice && ( -
- -
-
- {composerNotice.title} -
-
- {composerNotice.description} -
- {composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && ( - - )} -
-
- )} +
= ({ gap: 8, padding: '8px 4px 8px' }}> -
- - -
- - {contextExpanded && activeContextItems.length > 0 && ( -
-
当前上下文 · {activeContextItems.length}
- {activeContextItems.map((ctx, idx) => ( - { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }} - className="gn-v2-ai-context-table-chip" - style={{ margin: 0 }} - > - - {ctx.tableName} - - ))} -
- )} - - {draftImages.length > 0 && ( -
- {draftImages.map((b64, i) => ( -
- {`Draft - -
- ))} -
- )} - {composerNotice && ( -
- -
-
- {composerNotice.title} -
-
- {composerNotice.description} -
- {composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && ( - - )} -
-
- )} + setContextExpanded(!contextExpanded)} + onOpenContext={handleOpenContext} + onRemoveContext={handleRemoveContextItem} + /> + +
{ + const value = Math.floor(Number(input) || DEFAULT_DDL_INCLUDE_LIMIT); + if (value < 200) return 200; + if (value > 12000) return 12000; + return value; +}; + +const buildConnectionKey = (activeContext?: { connectionId: string; dbName: string } | null): string => + activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; + +const sliceText = (value: string, limit: number): { text: string; truncated: boolean; charCount: number } => { + const normalized = String(value || '').trim(); + const visible = normalized.slice(0, limit); + return { + text: visible, + truncated: normalized.length > visible.length, + charCount: normalized.length, + }; +}; + +const buildTableContextSnapshot = (params: { + item: AIContextItem; + includeDDL: boolean; + ddlLimit: number; +}) => { + const { item, includeDDL, ddlLimit } = params; + const preview = sliceText(item.ddl, DEFAULT_DDL_PREVIEW_LIMIT); + const ddl = includeDDL ? sliceText(item.ddl, ddlLimit) : null; + + return { + dbName: item.dbName, + tableName: item.tableName, + ddlPreview: preview.text, + ddlPreviewTruncated: preview.truncated, + ddlCharCount: preview.charCount, + ddl: ddl?.text, + ddlTruncated: ddl?.truncated || false, + }; +}; + +export const buildAIContextSnapshot = (params: { + activeContext?: { connectionId: string; dbName: string } | null; + aiContexts?: Record; + connections: SavedConnection[]; + includeDDL?: boolean; + ddlLimit?: unknown; +}) => { + const { + activeContext = null, + aiContexts = {}, + connections, + includeDDL = false, + ddlLimit, + } = params; + const contextKey = buildConnectionKey(activeContext); + const activeContextItems = aiContexts[contextKey] || []; + const activeConnection = activeContext?.connectionId + ? connections.find((connection) => connection.id === activeContext.connectionId) + : undefined; + + return { + hasActiveContext: activeContextItems.length > 0, + contextKey, + connectionId: activeContext?.connectionId || '', + connectionName: activeConnection?.name || '', + connectionType: activeConnection?.config?.type || '', + dbName: activeContext?.dbName || '', + tableCount: activeContextItems.length, + includeDDL, + tables: activeContextItems.map((item) => + buildTableContextSnapshot({ + item, + includeDDL, + ddlLimit: normalizeDDLLimit(ddlLimit), + })), + message: activeContextItems.length > 0 + ? `当前已关联 ${activeContextItems.length} 张表结构上下文` + : '当前没有已关联的 AI 表结构上下文', + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 150d77d..acd3300 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -133,6 +133,42 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('SELECT * FROM orders'); }); + it('returns the current linked AI context so the model can inspect which table schemas are already mounted', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_context', { + includeDDL: true, + ddlLimit: 80, + }), + connections: [buildConnection()], + activeContext: { + connectionId: 'conn-1', + dbName: 'crm', + }, + aiContexts: { + 'conn-1:crm': [ + { + dbName: 'crm', + tableName: 'orders', + ddl: 'CREATE TABLE orders (id bigint primary key, status varchar(32), amount decimal(10,2));', + }, + ], + }, + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"hasActiveContext":true'); + expect(result.content).toContain('"connectionName":"主库"'); + expect(result.content).toContain('"tableName":"orders"'); + expect(result.content).toContain('"includeDDL":true'); + expect(result.content).toContain('CREATE TABLE orders'); + }); + it('blocks execute_sql when the AI safety check rejects the statement', async () => { const query = vi.fn(); const result = await executeLocalAIToolCall({ diff --git a/frontend/src/components/ai/aiLocalToolExecutor.ts b/frontend/src/components/ai/aiLocalToolExecutor.ts index ea8b3f5..9858d53 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.ts @@ -1,11 +1,12 @@ import { DBGetAllColumns, DBGetDatabases, DBGetTables } from '../../../wailsjs/go/app/App'; import type { SqlLog } from '../../store'; -import type { AIChatMessage, AIMCPToolDescriptor, AIToolCall, SavedConnection, TabData } from '../../types'; +import type { AIChatMessage, AIContextItem, 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 { buildAIContextSnapshot } from './aiContextInsights'; import { buildActiveTabSnapshot, buildRecentSqlLogsSnapshot, @@ -35,6 +36,8 @@ interface AILocalToolRuntime { export interface ExecuteLocalAIToolCallOptions { toolCall: AIToolCall; connections: SavedConnection[]; + activeContext?: { connectionId: string; dbName: string } | null; + aiContexts?: Record; tabs?: TabData[]; activeTabId?: string | null; mcpTools: AIMCPToolDescriptor[]; @@ -160,6 +163,8 @@ const buildPreviewSQLForTable = (connection: SavedConnection, tableName: string, export async function executeLocalAIToolCall({ toolCall, connections, + activeContext = null, + aiContexts = {}, tabs = [], activeTabId = null, mcpTools, @@ -204,6 +209,21 @@ export async function executeLocalAIToolCall({ } break; } + case 'inspect_ai_context': { + try { + content = JSON.stringify(buildAIContextSnapshot({ + activeContext, + aiContexts, + connections, + includeDDL: args.includeDDL === true, + ddlLimit: args.ddlLimit, + })); + success = true; + } catch (error: any) { + content = `读取当前 AI 上下文失败: ${error?.message || error}`; + } + break; + } case 'get_connections': { const availableConnections = connections.map((connection) => ({ id: connection.id, diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts new file mode 100644 index 0000000..19b2479 --- /dev/null +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -0,0 +1,20 @@ +import type { AISlashCommandDefinition } from './AISlashCommandMenu'; + +export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [ + { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, + { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, + { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' }, + { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' }, + { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' }, + { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' }, + { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' }, + { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' }, +]; + +export const filterAISlashCommands = (filter: string): AISlashCommandDefinition[] => { + const normalized = String(filter || '').trim().toLowerCase(); + if (!normalized) { + return DEFAULT_AI_SLASH_COMMANDS; + } + return DEFAULT_AI_SLASH_COMMANDS.filter((command) => command.cmd.startsWith(normalized)); +}; diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 2c0ad6f..000a210 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -68,13 +68,14 @@ describe('buildAISystemContextMessages', () => { connections: [connections[0]], tabs: [], activeTabId: null, - availableToolNames: ['inspect_workspace_tabs', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_context', 'get_columns'], skills, userPromptSettings, }); const joined = messages.map((message) => message.content).join('\n'); expect(joined).toContain('inspect_workspace_tabs 盘点当前工作区'); + expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文'); expect(joined).toContain('以下是当前用户的自定义补充提示词(全局)'); expect(joined).toContain('以下是当前用户的自定义补充提示词(数据库会话)'); expect(joined).toContain('以下是当前启用的 Skill「结构审查」'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 734da41..049c9c9 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -303,6 +303,13 @@ SELECT * FROM users WHERE status = 1; }); } + if (availableToolNames.includes('inspect_ai_context')) { + systemMessages.push({ + role: 'system', + content: '如果用户提到“当前 AI 上下文”“当前关联了哪些表”“现在带了哪些表结构”,优先调用 inspect_ai_context 读取当前挂载的表结构上下文,不要凭记忆复述。', + }); + } + appendCustomPromptGroup(systemMessages, ['database'], userPromptSettings); appendSkillPromptGroup(systemMessages, ['database'], skills, availableToolNames); return systemMessages; diff --git a/frontend/src/utils/aiToolRegistry.ts b/frontend/src/utils/aiToolRegistry.ts index 013cbdd..f505602 100644 --- a/frontend/src/utils/aiToolRegistry.ts +++ b/frontend/src/utils/aiToolRegistry.ts @@ -309,6 +309,29 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_ai_context", + icon: "🧷", + desc: "查看当前 AI 已关联的表结构上下文", + detail: + "返回当前对话已经挂载到 AI 上下文里的表清单、所属连接与数据库,以及每张表的 DDL 预览。适合用户说“看看我现在带了哪些表结构”“当前 AI 上下文是什么”时,先读取真实挂载状态再继续分析。", + params: "includeDDL?(默认 false), ddlLimit?(默认 4000)", + tool: { + type: "function", + function: { + name: "inspect_ai_context", + description: + "读取当前对话已经关联到 AI 上下文里的表结构快照,包括连接、数据库、表名,以及可选的 DDL 内容。适用于用户提到当前 AI 上下文、当前关联表、当前挂载的表结构时,先读取真实状态,避免模型凭记忆复述。", + parameters: { + type: "object", + properties: { + includeDDL: { type: "boolean", description: "可选,是否附带每张表的 DDL 内容,默认 false" }, + ddlLimit: { type: "number", description: "可选,DDL 截断长度,默认 4000,最大 12000" }, + }, + }, + }, + }, + }, { name: "inspect_active_tab", icon: "📍",