feat(ai-chat): 新增上下文探针并拆分输入区组件

- 新增 inspect_ai_context 内置工具与系统提示引导
- 拆分 AIChatInput 的上下文、附件、提示条和斜杠命令模块
- 补充工具目录、执行器与输入区相关测试
This commit is contained in:
Syngnat
2026-06-08 17:58:52 +08:00
parent 5f3d1d9880
commit 2e5c3473e1
14 changed files with 537 additions and 215 deletions

View File

@@ -1056,6 +1056,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
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,

View File

@@ -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(
<AIBuiltinToolsCatalog
darkMode={false}
@@ -26,6 +26,8 @@ describe('AIBuiltinToolsCatalog', () => {
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('盘点当前工作区');

View File

@@ -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',

View File

@@ -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<AIChatAttachmentStripProps> = ({
draftImages,
onRemove,
overlayTheme,
variant,
}) => {
if (draftImages.length === 0) {
return null;
}
if (variant === 'v2') {
return (
<div className="gn-v2-ai-attachment-row">
{draftImages.map((b64, index) => (
<div key={index} className="gn-v2-ai-attachment-thumb">
<img src={b64} alt={`Draft ${index}`} />
<button
type="button"
onClick={() => onRemove(index)}
aria-label="移除图片"
>
</button>
</div>
))}
</div>
);
}
return (
<>
{draftImages.map((b64, index) => (
<div key={index} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${index}`} />
<div
onClick={() => 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 }}
>
</div>
</div>
))}
</>
);
};
export default AIChatAttachmentStrip;

View File

@@ -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<AIChatComposerNoticeProps> = ({
composerNotice,
darkMode,
textColor,
mutedColor,
onComposerNoticeAction,
}) => {
if (!composerNotice) {
return null;
}
const palette = resolveNoticePalette(composerNotice.tone, darkMode);
const actionLabel = composerNotice.action?.label;
return (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: palette.background,
border: `1px solid ${palette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: palette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
{actionLabel && typeof onComposerNoticeAction === 'function' && (
<Button
size="small"
type="default"
onClick={onComposerNoticeAction}
style={{ marginTop: 8, borderRadius: 8 }}
>
{actionLabel}
</Button>
)}
</div>
</div>
);
};
export default AIChatComposerNotice;

View File

@@ -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) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(event) => {
event.preventDefault();
onRemoveContext(ctx.dbName, ctx.tableName);
}}
className={className}
style={style}
>
<TableOutlined />
<span>{ctx.tableName}</span>
</Tag>
));
export const AIChatContextPreview: React.FC<AIChatContextPreviewProps> = ({
variant,
activeContextItems,
contextExpanded,
darkMode,
textColor,
onToggleExpanded,
onOpenContext,
onRemoveContext,
}) => {
if (variant === 'v2') {
return (
<>
<div className="ai-chat-input-preview-area gn-v2-ai-context-row">
<button
type="button"
className={`gn-v2-ai-context-toggle${contextExpanded ? ' is-expanded' : ''}`}
onClick={onToggleExpanded}
aria-expanded={contextExpanded}
>
<TableOutlined />
<span></span>
<strong>{activeContextItems.length}</strong>
<DownOutlined />
</button>
<button
type="button"
className="gn-v2-ai-context-add"
onClick={onOpenContext}
>
<PlusOutlined />
<span></span>
</button>
</div>
{contextExpanded && activeContextItems.length > 0 && (
<div className="gn-v2-ai-context-detail" data-ai-context-detail="true">
<div className="gn-v2-ai-context-detail-title"> · {activeContextItems.length}</div>
{renderContextTableChips(activeContextItems, onRemoveContext, 'gn-v2-ai-context-table-chip', { margin: 0 })}
</div>
)}
</>
);
}
if (activeContextItems.length === 0) {
return null;
}
return (
<>
<Tag
onClick={onToggleExpanded}
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' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
{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;

View File

@@ -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<AIChatInputProps> = ({
const [appendingContext, setAppendingContext] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<AIChatInputProps> = ({
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<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 语句:' },
], []);
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<AIChatInputProps> = ({
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<AIChatInputProps> = ({
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 (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
@@ -309,73 +281,30 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{activeContextItems.length > 0 && (
<Tag
onClick={() => 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' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
)}
{contextExpanded && activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { 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 }}
>
<span style={{ fontSize: 13 }}>🗄 {ctx.tableName}</span>
</Tag>
))}
{draftImages.map((b64, i) => (
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
<div
onClick={() => 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 }}
>
</div>
</div>
))}
<AIChatContextPreview
variant="legacy"
activeContextItems={activeContextItems}
contextExpanded={contextExpanded}
darkMode={darkMode}
textColor={textColor}
onToggleExpanded={() => setContextExpanded(!contextExpanded)}
onOpenContext={handleOpenContext}
onRemoveContext={handleRemoveContextItem}
/>
<AIChatAttachmentStrip
variant="legacy"
draftImages={draftImages}
overlayTheme={overlayTheme}
onRemove={handleRemoveDraftImage}
/>
</div>
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: composerNoticePalette.background,
border: `1px solid ${composerNoticePalette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
{composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && (
<Button
size="small"
type="default"
onClick={onComposerNoticeAction}
style={{ marginTop: 8, borderRadius: 8 }}
>
{composerNoticeActionLabel}
</Button>
)}
</div>
</div>
)}
<AIChatComposerNotice
composerNotice={composerNotice}
darkMode={darkMode}
textColor={textColor}
mutedColor={mutedColor}
onComposerNoticeAction={onComposerNoticeAction}
/>
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
<AISlashCommandMenu
visible={showSlashMenu}
@@ -557,96 +486,29 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
gap: 8,
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area gn-v2-ai-context-row">
<button
type="button"
className={`gn-v2-ai-context-toggle${contextExpanded ? ' is-expanded' : ''}`}
onClick={() => setContextExpanded(!contextExpanded)}
aria-expanded={contextExpanded}
>
<TableOutlined />
<span></span>
<strong>{activeContextItems.length}</strong>
<DownOutlined />
</button>
<button
type="button"
className="gn-v2-ai-context-add"
onClick={handleOpenContext}
>
<PlusOutlined />
<span></span>
</button>
</div>
{contextExpanded && activeContextItems.length > 0 && (
<div className="gn-v2-ai-context-detail" data-ai-context-detail="true">
<div className="gn-v2-ai-context-detail-title"> · {activeContextItems.length}</div>
{activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
className="gn-v2-ai-context-table-chip"
style={{ margin: 0 }}
>
<TableOutlined />
<span>{ctx.tableName}</span>
</Tag>
))}
</div>
)}
{draftImages.length > 0 && (
<div className="gn-v2-ai-attachment-row">
{draftImages.map((b64, i) => (
<div key={i} className="gn-v2-ai-attachment-thumb">
<img src={b64} alt={`Draft ${i}`} />
<button
type="button"
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
aria-label="移除图片"
>
</button>
</div>
))}
</div>
)}
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: composerNoticePalette.background,
border: `1px solid ${composerNoticePalette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
{composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && (
<Button
size="small"
type="default"
onClick={onComposerNoticeAction}
style={{ marginTop: 8, borderRadius: 8 }}
>
{composerNoticeActionLabel}
</Button>
)}
</div>
</div>
)}
<AIChatContextPreview
variant="v2"
activeContextItems={activeContextItems}
contextExpanded={contextExpanded}
darkMode={darkMode}
textColor={textColor}
onToggleExpanded={() => setContextExpanded(!contextExpanded)}
onOpenContext={handleOpenContext}
onRemoveContext={handleRemoveContextItem}
/>
<AIChatAttachmentStrip
variant="v2"
draftImages={draftImages}
overlayTheme={overlayTheme}
onRemove={handleRemoveDraftImage}
/>
<AIChatComposerNotice
composerNotice={composerNotice}
darkMode={darkMode}
textColor={textColor}
mutedColor={mutedColor}
onComposerNoticeAction={onComposerNoticeAction}
/>
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
<AISlashCommandMenu
visible={showSlashMenu}

View File

@@ -0,0 +1,85 @@
import type { AIContextItem, SavedConnection } from '../../types';
const DEFAULT_DDL_PREVIEW_LIMIT = 320;
const DEFAULT_DDL_INCLUDE_LIMIT = 4000;
const normalizeDDLLimit = (input: unknown): number => {
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<string, AIContextItem[]>;
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 表结构上下文',
};
};

View File

@@ -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({

View File

@@ -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<string, AIContextItem[]>;
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,

View File

@@ -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));
};

View File

@@ -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「结构审查」');

View File

@@ -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;

View File

@@ -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: "📍",