♻️ refactor(ai-input): 拆分上下文弹窗并完善命令反馈

- 抽离 AI 输入区的上下文选表弹窗和斜杠菜单,降低 AIChatInput 体积与重复逻辑
- 合并图片粘贴与 slash 状态处理,减少 V1/V2 两套输入分支的重复代码
- 新增 /命令无匹配空状态提示,并补做测试、构建与真实页面验证
This commit is contained in:
Syngnat
2026-06-07 23:19:38 +08:00
parent 802385464d
commit 67dd178166
5 changed files with 455 additions and 365 deletions

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Input, Select, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { CodeOutlined, DatabaseOutlined, DownOutlined, PlusOutlined, SendOutlined, StopOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { Input, Select, Tooltip, message, Button, Tag } from 'antd';
import { CodeOutlined, DatabaseOutlined, DownOutlined, PlusOutlined, SendOutlined, StopOutlined, TableOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
@@ -9,6 +9,8 @@ import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
import type { ShortcutPlatform, ShortcutPlatformBinding } from '../../utils/shortcuts';
import AIContextSelectorModal from './AIContextSelectorModal';
import AISlashCommandMenu, { type AISlashCommandDefinition } from './AISlashCommandMenu';
interface AIChatInputProps {
input: string;
@@ -110,7 +112,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
// Slash commands
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
const [slashFilter, setSlashFilter] = React.useState('');
const slashCommands = React.useMemo(() => [
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```' },
@@ -249,6 +251,51 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
}
};
const handlePasteImages = React.useCallback((event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
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);
}
}
}
}, [setDraftImages]);
const handleComposerInputChange = React.useCallback((value: string) => {
setInput(value);
if (value.startsWith('/')) {
setSlashFilter(value.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}, [setInput]);
const handleSelectSlashCommand = React.useCallback((command: AISlashCommandDefinition) => {
setInput(command.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}, [setInput, textareaRef]);
const handleOpenSlashMenu = React.useCallback(() => {
setInput('/');
setSlashFilter('/');
setShowSlashMenu(true);
textareaRef.current?.focus();
}, [setInput, textareaRef]);
if (!isV2Ui) {
return (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
@@ -330,71 +377,26 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div style={{
<AISlashCommandMenu
visible={showSlashMenu}
commands={filteredSlashCmds}
darkMode={darkMode}
textColor={textColor}
mutedColor={mutedColor}
onSelect={handleSelectSlashCommand}
style={{
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
maxHeight: 220, overflowY: 'auto', padding: 4
}}>
{filteredSlashCmds.map(cmd => (
<div
key={cmd.cmd}
style={{
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => {
setInput(cmd.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
</div>
))}
</div>
)}
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}}
/>
<Input.TextArea
onPaste={handlePasteImages}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onChange={(e) => handleComposerInputChange(e.target.value)}
onKeyDown={handleKeyDown as any}
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
@@ -519,129 +521,27 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
</div>
<Modal
title={<span style={{ color: textColor }}></span>}
<AIContextSelectorModal
open={contextOpen}
onCancel={() => setContextOpen(false)}
onOk={handleAppendContext}
loading={contextLoading}
confirmLoading={appendingContext}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' }
darkMode={darkMode}
textColor={textColor}
overlayTheme={overlayTheme}
dbList={dbList}
selectedDbName={selectedDbName}
searchText={searchText}
filteredTables={filteredTables}
selectedTableKeys={selectedTableKeys}
onCancel={() => setContextOpen(false)}
onConfirm={handleAppendContext}
onDbChange={(value) => {
const connection = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (connection) fetchTablesForDb(value, connection.config);
}}
>
<Spin spinning={contextLoading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={val => {
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (c) fetchTablesForDb(val, c.config);
}}
options={dbList.map(d => ({ label: d, value: d }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
<Checkbox
indeterminate={
filteredTables.length > 0 &&
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
}
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
onChange={(e) => {
if (e.target.checked) {
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
setSelectedTableKeys(Array.from(newSelected));
} else {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
}
}}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={() => {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
setSelectedTableKeys([...remainingSelected, ...toAdd]);
}}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map(t => {
const key = `${selectedDbName}::${t.name}`;
const isSelected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={(e) => {
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
if (isSelected) {
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
} else {
setSelectedTableKeys([...selectedTableKeys, key]);
}
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
}}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
'{searchText}'
</div>
)}
</Spin>
</Modal>
onSearchTextChange={setSearchText}
onSelectedTableKeysChange={setSelectedTableKeys}
/>
</div>
);
}
@@ -748,70 +648,25 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
)}
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div className="gn-v2-ai-slash-menu" style={{
<AISlashCommandMenu
visible={showSlashMenu}
commands={filteredSlashCmds}
darkMode={darkMode}
textColor={textColor}
mutedColor={mutedColor}
className="gn-v2-ai-slash-menu"
style={{
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
}}>
{filteredSlashCmds.map(cmd => (
<div
key={cmd.cmd}
style={{
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => {
setInput(cmd.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
</div>
))}
</div>
)}
}}
onSelect={handleSelectSlashCommand}
/>
<div className="gn-v2-ai-input-surface">
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}}
onPaste={handlePasteImages}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
// Slash command detection
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onChange={(e) => handleComposerInputChange(e.target.value)}
onKeyDown={handleKeyDown as any}
placeholder={`输入消息... ${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)} · / 命令`}
variant="borderless"
@@ -847,12 +702,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
<Button
type="text"
icon={<CodeOutlined />}
onClick={() => {
setInput('/');
setSlashFilter('/');
setShowSlashMenu(true);
textareaRef.current?.focus();
}}
onClick={handleOpenSlashMenu}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
/>
</Tooltip>
@@ -924,130 +774,27 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
</div>
<Modal
title={<span style={{ color: textColor }}></span>}
<AIContextSelectorModal
open={contextOpen}
onCancel={() => setContextOpen(false)}
onOk={handleAppendContext}
loading={contextLoading}
confirmLoading={appendingContext}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' }
darkMode={darkMode}
textColor={textColor}
overlayTheme={overlayTheme}
dbList={dbList}
selectedDbName={selectedDbName}
searchText={searchText}
filteredTables={filteredTables}
selectedTableKeys={selectedTableKeys}
onCancel={() => setContextOpen(false)}
onConfirm={handleAppendContext}
onDbChange={(value) => {
const connection = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (connection) fetchTablesForDb(value, connection.config);
}}
>
<Spin spinning={contextLoading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={val => {
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (c) fetchTablesForDb(val, c.config);
}}
options={dbList.map(d => ({ label: d, value: d }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
<Checkbox
indeterminate={
filteredTables.length > 0 &&
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
}
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
onChange={(e) => {
if (e.target.checked) {
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
setSelectedTableKeys(Array.from(newSelected));
} else {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
}
}}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={() => {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
setSelectedTableKeys([...remainingSelected, ...toAdd]);
}}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map(t => {
const key = `${selectedDbName}::${t.name}`;
const isSelected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={(e) => {
// If click originated from the checkbox input itself, let its onChange handle it to avoid duplicate toggle
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
if (isSelected) {
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
} else {
setSelectedTableKeys([...selectedTableKeys, key]);
}
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
}}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
'{searchText}'
</div>
)}
</Spin>
</Modal>
onSearchTextChange={setSearchText}
onSelectedTableKeysChange={setSelectedTableKeys}
/>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./AIContextSelectorModal.tsx', import.meta.url), 'utf8');
describe('AIContextSelectorModal', () => {
it('keeps the batch-selection actions after extracting the context modal', () => {
expect(source).toContain('同步所选表至上下文');
expect(source).toContain('全选匹配的表');
expect(source).toContain('反选匹配结果');
expect(source).toContain('handleToggleAll');
expect(source).toContain('handleInvertSelection');
});
it('shows a dedicated empty-state copy for databases without tables', () => {
expect(source).toContain("当前数据库没有可关联的表");
expect(source).toContain("没有找到匹配");
});
});

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Button, Checkbox, Input, Modal, Select, Spin } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface ContextTableItem {
name: string;
}
interface AIContextSelectorModalProps {
open: boolean;
loading: boolean;
confirmLoading: boolean;
darkMode: boolean;
textColor: string;
overlayTheme: OverlayWorkbenchTheme;
dbList: string[];
selectedDbName: string;
searchText: string;
filteredTables: ContextTableItem[];
selectedTableKeys: string[];
onCancel: () => void;
onConfirm: () => void;
onDbChange: (dbName: string) => void;
onSearchTextChange: (value: string) => void;
onSelectedTableKeysChange: (keys: string[]) => void;
}
export const AIContextSelectorModal: React.FC<AIContextSelectorModalProps> = ({
open,
loading,
confirmLoading,
darkMode,
textColor,
overlayTheme,
dbList,
selectedDbName,
searchText,
filteredTables,
selectedTableKeys,
onCancel,
onConfirm,
onDbChange,
onSearchTextChange,
onSelectedTableKeysChange,
}) => {
const matchedKeys = filteredTables.map((table) => `${selectedDbName}::${table.name}`);
const allSelected = matchedKeys.length > 0 && matchedKeys.every((key) => selectedTableKeys.includes(key));
const partiallySelected = matchedKeys.length > 0 && matchedKeys.some((key) => selectedTableKeys.includes(key)) && !allSelected;
const handleToggleAll = (checked: boolean) => {
if (checked) {
const nextSelected = new Set([...selectedTableKeys, ...matchedKeys]);
onSelectedTableKeysChange(Array.from(nextSelected));
return;
}
onSelectedTableKeysChange(selectedTableKeys.filter((key) => !matchedKeys.includes(key)));
};
const handleInvertSelection = () => {
const remainingSelected = selectedTableKeys.filter((key) => !matchedKeys.includes(key));
const keysToAdd = matchedKeys.filter((key) => !selectedTableKeys.includes(key));
onSelectedTableKeysChange([...remainingSelected, ...keysToAdd]);
};
const handleToggleSingle = (key: string, checked: boolean) => {
if (checked) {
onSelectedTableKeysChange([...selectedTableKeys, key]);
return;
}
onSelectedTableKeysChange(selectedTableKeys.filter((selectedKey) => selectedKey !== key));
};
return (
<Modal
title={<span style={{ color: textColor }}></span>}
open={open}
onCancel={onCancel}
onOk={onConfirm}
confirmLoading={confirmLoading}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' },
}}
>
<Spin spinning={loading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={onDbChange}
options={dbList.map((dbName) => ({ label: dbName, value: dbName }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={(event) => onSearchTextChange(event.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,
paddingBottom: 12,
marginBottom: 8,
}}
>
<Checkbox
indeterminate={partiallySelected}
checked={allSelected}
onChange={(event) => handleToggleAll(event.target.checked)}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={handleInvertSelection}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map((table) => {
const key = `${selectedDbName}::${table.name}`;
const selected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer',
}}
onMouseEnter={(event) => {
event.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = 'transparent';
}}
onClick={(event) => {
if ((event.target as HTMLElement).tagName.toLowerCase() === 'input') {
return;
}
handleToggleSingle(key, !selected);
}}
>
<Checkbox
checked={selected}
onChange={(event) => handleToggleSingle(key, event.target.checked)}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{table.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
{searchText ? `没有找到匹配 '${searchText}' 的表` : '当前数据库没有可关联的表'}
</div>
)}
</Spin>
</Modal>
);
};
export default AIContextSelectorModal;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AISlashCommandMenu from './AISlashCommandMenu';
describe('AISlashCommandMenu', () => {
it('renders an empty-state hint when the slash filter has no matches', () => {
const markup = renderToStaticMarkup(
<AISlashCommandMenu
visible
commands={[]}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
onSelect={() => {}}
/>,
);
expect(markup).toContain('data-ai-chat-slash-empty="true"');
expect(markup).toContain('没有匹配的快捷命令');
expect(markup).toContain('/query');
});
it('renders slash command entries when matches exist', () => {
const markup = renderToStaticMarkup(
<AISlashCommandMenu
visible
commands={[{
cmd: '/sql',
label: '生成 SQL',
desc: '描述需求自动生成语句',
prompt: '请根据以下需求生成 SQL',
}]}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
onSelect={() => {}}
/>,
);
expect(markup).toContain('/sql');
expect(markup).toContain('生成 SQL');
expect(markup).not.toContain('没有匹配的快捷命令');
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
export interface AISlashCommandDefinition {
cmd: string;
label: string;
desc: string;
prompt: string;
}
interface AISlashCommandMenuProps {
visible: boolean;
commands: AISlashCommandDefinition[];
darkMode: boolean;
textColor: string;
mutedColor: string;
className?: string;
style?: React.CSSProperties;
onSelect: (command: AISlashCommandDefinition) => void;
}
export const AISlashCommandMenu: React.FC<AISlashCommandMenuProps> = ({
visible,
commands,
darkMode,
textColor,
mutedColor,
className,
style,
onSelect,
}) => {
if (!visible) {
return null;
}
return (
<div
data-ai-chat-slash-menu="true"
className={className}
style={style}
>
{commands.length > 0 ? commands.map((command) => (
<div
key={command.cmd}
style={{
padding: '8px 12px',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'background 0.15s',
}}
onMouseEnter={(event) => {
event.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = 'transparent';
}}
onClick={() => onSelect(command)}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{command.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{command.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{command.desc}</span>
</div>
)) : (
<div
data-ai-chat-slash-empty="true"
style={{
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor }}>
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5 }}>
`/query``/sql``/explain``/optimize`
</div>
</div>
)}
</div>
);
};
export default AISlashCommandMenu;