mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 03:59:41 +08:00
✨ feat(ai-chat): 新增上下文探针并拆分输入区组件
- 新增 inspect_ai_context 内置工具与系统提示引导 - 拆分 AIChatInput 的上下文、附件、提示条和斜杠命令模块 - 补充工具目录、执行器与输入区相关测试
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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('盘点当前工作区');
|
||||
|
||||
@@ -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',
|
||||
|
||||
58
frontend/src/components/ai/AIChatAttachmentStrip.tsx
Normal file
58
frontend/src/components/ai/AIChatAttachmentStrip.tsx
Normal 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;
|
||||
93
frontend/src/components/ai/AIChatComposerNotice.tsx
Normal file
93
frontend/src/components/ai/AIChatComposerNotice.tsx
Normal 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;
|
||||
108
frontend/src/components/ai/AIChatContextPreview.tsx
Normal file
108
frontend/src/components/ai/AIChatContextPreview.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
85
frontend/src/components/ai/aiContextInsights.ts
Normal file
85
frontend/src/components/ai/aiContextInsights.ts
Normal 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 表结构上下文',
|
||||
};
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
frontend/src/components/ai/aiSlashCommands.ts
Normal file
20
frontend/src/components/ai/aiSlashCommands.ts
Normal 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));
|
||||
};
|
||||
@@ -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「结构审查」');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "📍",
|
||||
|
||||
Reference in New Issue
Block a user