import React from 'react'; import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd'; import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons'; import { useStore } from '../../store'; import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; interface AIChatInputProps { input: string; setInput: (val: string) => void; draftImages: string[]; setDraftImages: React.Dispatch>; sending: boolean; onSend: () => void; onStop: () => void; handleKeyDown: (e: React.KeyboardEvent) => void; activeConnName: string; activeContext: any; activeProvider: any; dynamicModels: string[]; loadingModels: boolean; onModelChange: (val: string) => void; onFetchModels: () => void; textareaRef: React.RefObject; darkMode: boolean; textColor: string; mutedColor: string; overlayTheme: OverlayWorkbenchTheme; contextUsageChars?: number; maxContextChars?: number; } export const AIChatInput: React.FC = ({ input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown, activeConnName, activeContext, activeProvider, dynamicModels, loadingModels, onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme, contextUsageChars, maxContextChars }) => { const [contextOpen, setContextOpen] = React.useState(false); const [contextLoading, setContextLoading] = React.useState(false); const [contextTables, setContextTables] = React.useState<{name: string}[]>([]); const [selectedTableKeys, setSelectedTableKeys] = React.useState([]); const [searchText, setSearchText] = React.useState(''); const [appendingContext, setAppendingContext] = React.useState(false); const fileInputRef = React.useRef(null); const handleImageUpload = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); files.forEach(file => { if (file.type.indexOf('image') !== -1) { const reader = new FileReader(); reader.onload = (event) => { if (event.target?.result) { setDraftImages(prev => [...prev, event.target!.result as string]); } }; reader.readAsDataURL(file); } }); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const [dbList, setDbList] = React.useState([]); const [selectedDbName, setSelectedDbName] = React.useState(''); const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase())); const [contextExpanded, setContextExpanded] = React.useState(false); // Slash commands const [showSlashMenu, setShowSlashMenu] = React.useState(false); const [slashFilter, setSlashFilter] = React.useState(''); const slashCommands = React.useMemo(() => [ { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:' }, { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' }, { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' }, { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' }, { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' }, { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' }, { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' }, ], []); const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase())); const aiContexts = useStore(state => state.aiContexts); const addAIContext = useStore(state => state.addAIContext); const removeAIContext = useStore(state => state.removeAIContext); const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; const activeContextItems = aiContexts[connectionKey] || []; const fetchTablesForDb = async (dbName: string, connConfig: any) => { setContextLoading(true); setSelectedDbName(dbName); try { const res = await DBGetTables(connConfig, dbName); if (res.success && Array.isArray(res.data)) { setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string }))); } else { message.error('获取表格失败: ' + res.message); setContextTables([]); } } catch (e: any) { message.error(e.message); setContextTables([]); } finally { setContextLoading(false); } }; const handleOpenContext = async () => { if (!activeContext?.connectionId) { message.warning('请先在左侧选择一个数据库作为所聊上下文'); return; } const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId); if (!conn) return; setContextOpen(true); setContextLoading(true); setSearchText(''); // Store dbName::tableName composite keys setSelectedTableKeys(activeContextItems.map(c => `${c.dbName}::${c.tableName}`)); try { // Fetch databases const dbRes = await DBGetDatabases(conn.config as any); if (dbRes.success && Array.isArray(dbRes.data)) { const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string); setDbList(databases); } // Fetch tables for the active contextual database const initDbName = activeContext.dbName || ''; setSelectedDbName(initDbName); const tablesRes = await DBGetTables(conn.config as any, initDbName); if (tablesRes.success && Array.isArray(tablesRes.data)) { setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string }))); } else { setContextTables([]); } } catch (e: any) { message.error(e.message); } finally { setContextLoading(false); } }; const handleAppendContext = async () => { const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId); if (!conn) return; setAppendingContext(true); try { let addedCount = 0; let removedCount = 0; for (const cx of activeContextItems) { const key = `${cx.dbName}::${cx.tableName}`; if (!selectedTableKeys.includes(key)) { removeAIContext(connectionKey, cx.dbName, cx.tableName); removedCount++; } } for (const key of selectedTableKeys) { const [dbName, tableName] = key.split('::'); if (!dbName || !tableName) continue; if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) { continue; } const res = await DBShowCreateTable(conn.config as any, dbName, tableName); let createSql = ''; if (res.success && res.data) { if (typeof res.data === 'string') { createSql = res.data; } else if (Array.isArray(res.data) && res.data.length > 0) { const row = res.data[0]; createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string; } } else { message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误')); } if (createSql) { addAIContext(connectionKey, { dbName: dbName, tableName: tableName, ddl: createSql }); addedCount++; } } if (addedCount > 0 || removedCount > 0) { if (addedCount > 0 && removedCount === 0) { message.success(`已添加 ${addedCount} 张表的结构到上下文`); } else if (removedCount > 0 && addedCount === 0) { message.success(`已从上下文移除 ${removedCount} 张表的结构`); } else { message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`); } if (addedCount > 0) setContextExpanded(true); } else { message.info('选中的表未发生变化'); } setContextOpen(false); } catch (e: any) { message.error(e.message); } finally { setAppendingContext(false); } }; return (
{activeContextItems.length > 0 && ( setContextExpanded(!contextExpanded)} style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }} > 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'} )} {contextExpanded && activeContextItems.map((ctx, idx) => ( { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }} style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }} > 🗄️ {ctx.tableName} ))} {draftImages.map((b64, i) => (
{`Draft
setDraftImages(prev => prev.filter((_, idx) => idx !== i))} style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }} > ✕
))}
{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); } } } }} 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(''); } }} onKeyDown={handleKeyDown as any} placeholder="输入消息... (Enter 发送,Shift+Enter 换行,/ 快捷命令)" variant="borderless" autoSize={{ minRows: 1, maxRows: 8 }} style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }} />
{activeConnName && (
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
)} {activeProvider && ( ) : ( )}
关联数据库表结构上下文} open={contextOpen} onCancel={() => setContextOpen(false)} onOk={handleAppendContext} 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' } }} >
{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}' 的表
)}
); };