diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index 7bc97ad..5611d25 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Input, Select, Tooltip, 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 = ({ // Slash commands const [showSlashMenu, setShowSlashMenu] = React.useState(false); const [slashFilter, setSlashFilter] = React.useState(''); - const slashCommands = React.useMemo(() => [ + const slashCommands = React.useMemo(() => [ { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' }, @@ -249,6 +251,51 @@ export const AIChatInput: React.FC = ({ } }; + const handlePasteImages = React.useCallback((event: React.ClipboardEvent) => { + 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 (
@@ -330,71 +377,26 @@ export const AIChatInput: React.FC = ({
)}
- {showSlashMenu && filteredSlashCmds.length > 0 && ( -
- {filteredSlashCmds.map(cmd => ( -
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(); - }} - > - {cmd.cmd} - {cmd.label} - {cmd.desc} -
- ))} -
- )} - { - 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); - } - } - } }} + /> + { - 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 = ({
- 关联数据库表结构上下文} + 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); }} - > - -
- {dbList.length > 0 && ( - } - 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 }} - /> -
- {filteredTables.length > 0 ? ( -
-
- 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}) - - -
-
-
- {filteredTables.map(t => { - const key = `${selectedDbName}::${t.name}`; - const isSelected = selectedTableKeys.includes(key); - return ( -
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]); - } - }} - > - { - if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]); - else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key)); - }} - style={{ color: textColor, width: '100%' }} - > - {t.name} - -
- ); - })} -
-
-
- ) : ( -
- 没有找到匹配 '{searchText}' 的表 -
- )} -
-
+ onSearchTextChange={setSearchText} + onSelectedTableKeysChange={setSelectedTableKeys} + /> ); } @@ -748,70 +648,25 @@ export const AIChatInput: React.FC = ({ )}
- {showSlashMenu && filteredSlashCmds.length > 0 && ( -
- {filteredSlashCmds.map(cmd => ( -
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(); - }} - > - {cmd.cmd} - {cmd.label} - {cmd.desc} -
- ))} -
- )} + }} + onSelect={handleSelectSlashCommand} + />
{ - 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 = ({
- 关联数据库表结构上下文} + 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); }} - > - -
- {dbList.length > 0 && ( - } - 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 }} - /> -
- {filteredTables.length > 0 ? ( -
-
- 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}) - - -
-
-
- {filteredTables.map(t => { - const key = `${selectedDbName}::${t.name}`; - const isSelected = selectedTableKeys.includes(key); - return ( -
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]); - } - }} - > - { - if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]); - else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key)); - }} - style={{ color: textColor, width: '100%' }} - > - {t.name} - -
- ); - })} -
-
-
- ) : ( -
- 没有找到匹配 '{searchText}' 的表 -
- )} -
-
+ onSearchTextChange={setSearchText} + onSelectedTableKeysChange={setSelectedTableKeys} + /> ); }; diff --git a/frontend/src/components/ai/AIContextSelectorModal.test.tsx b/frontend/src/components/ai/AIContextSelectorModal.test.tsx new file mode 100644 index 0000000..e6fb04e --- /dev/null +++ b/frontend/src/components/ai/AIContextSelectorModal.test.tsx @@ -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("没有找到匹配"); + }); +}); diff --git a/frontend/src/components/ai/AIContextSelectorModal.tsx b/frontend/src/components/ai/AIContextSelectorModal.tsx new file mode 100644 index 0000000..79f4018 --- /dev/null +++ b/frontend/src/components/ai/AIContextSelectorModal.tsx @@ -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 = ({ + 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 ( + 关联数据库表结构上下文} + 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' }, + }} + > + +
+ {dbList.length > 0 && ( + } + 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 }} + /> +
+ + {filteredTables.length > 0 ? ( +
+
+ handleToggleAll(event.target.checked)} + style={{ color: textColor, fontWeight: 'bold' }} + > + 全选匹配的表 ({filteredTables.length}) + + +
+
+
+ {filteredTables.map((table) => { + const key = `${selectedDbName}::${table.name}`; + const selected = selectedTableKeys.includes(key); + return ( +
{ + 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); + }} + > + handleToggleSingle(key, event.target.checked)} + style={{ color: textColor, width: '100%' }} + > + {table.name} + +
+ ); + })} +
+
+
+ ) : ( +
+ {searchText ? `没有找到匹配 '${searchText}' 的表` : '当前数据库没有可关联的表'} +
+ )} +
+
+ ); +}; + +export default AIContextSelectorModal; diff --git a/frontend/src/components/ai/AISlashCommandMenu.test.tsx b/frontend/src/components/ai/AISlashCommandMenu.test.tsx new file mode 100644 index 0000000..909b512 --- /dev/null +++ b/frontend/src/components/ai/AISlashCommandMenu.test.tsx @@ -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( + {}} + />, + ); + + 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( + {}} + />, + ); + + expect(markup).toContain('/sql'); + expect(markup).toContain('生成 SQL'); + expect(markup).not.toContain('没有匹配的快捷命令'); + }); +}); diff --git a/frontend/src/components/ai/AISlashCommandMenu.tsx b/frontend/src/components/ai/AISlashCommandMenu.tsx new file mode 100644 index 0000000..01b3fe1 --- /dev/null +++ b/frontend/src/components/ai/AISlashCommandMenu.tsx @@ -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 = ({ + visible, + commands, + darkMode, + textColor, + mutedColor, + className, + style, + onSelect, +}) => { + if (!visible) { + return null; + } + + return ( +
+ {commands.length > 0 ? commands.map((command) => ( +
{ + 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)} + > + {command.cmd} + {command.label} + {command.desc} +
+ )) : ( +
+
+ 没有匹配的快捷命令 +
+
+ 可尝试 `/query`、`/sql`、`/explain`、`/optimize` 等内置命令。 +
+
+ )} +
+ ); +}; + +export default AISlashCommandMenu;