From 15e0766bbbb6bb59a9a3f108f96668275b43fe67 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 05:29:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai-chat):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E7=B1=BB=20slash=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=B9=B6=E6=8B=86=E5=88=86=E8=BE=93=E5=85=A5=E5=8C=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ai/AIChatInput.tsx | 273 ++++-------------- .../components/ai/AISlashCommandMenu.test.tsx | 8 +- .../src/components/ai/AISlashCommandMenu.tsx | 109 +++++-- .../src/components/ai/aiSlashCommands.test.ts | 39 +++ frontend/src/components/ai/aiSlashCommands.ts | 89 +++++- .../components/ai/useAIChatContextBinding.ts | 208 +++++++++++++ .../src/components/ai/useAIChatDraftImages.ts | 60 ++++ .../components/ai/useAISlashCommandMenu.ts | 61 ++++ 8 files changed, 579 insertions(+), 268 deletions(-) create mode 100644 frontend/src/components/ai/aiSlashCommands.test.ts create mode 100644 frontend/src/components/ai/useAIChatContextBinding.ts create mode 100644 frontend/src/components/ai/useAIChatDraftImages.ts create mode 100644 frontend/src/components/ai/useAISlashCommandMenu.ts diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index 69afd47..763e376 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -1,24 +1,23 @@ import React from 'react'; -import { Input, Tooltip, message, Button } from 'antd'; +import { Input, Tooltip, Button } from 'antd'; import { CodeOutlined, DatabaseOutlined, 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'; import type { AIComposerNotice, AIComposerNoticeAction } from '../../utils/aiComposerNotice'; import type { AIProviderConfig } from '../../types'; -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'; +import AISlashCommandMenu from './AISlashCommandMenu'; import AIChatComposerNotice from './AIChatComposerNotice'; import AIChatComposerStatus from './AIChatComposerStatus'; import AIChatAttachmentStrip from './AIChatAttachmentStrip'; import AIChatContextPreview from './AIChatContextPreview'; import AIChatProviderModelSelect from './AIChatProviderModelSelect'; import { buildAIChatReadinessSnapshot } from './aiChatReadiness'; -import { filterAISlashCommands } from './aiSlashCommands'; +import { useAIChatContextBinding } from './useAIChatContextBinding'; +import { useAIChatDraftImages } from './useAIChatDraftImages'; +import { useAISlashCommandMenu } from './useAISlashCommandMenu'; interface AIChatInputProps { input: string; @@ -57,47 +56,6 @@ export const AIChatInput: React.FC = ({ onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme, contextUsageChars, maxContextChars, isV2Ui = false }) => { - 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 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) => { - const files = Array.from(e.target.files || []); - files.forEach(file => { - if (file.type.indexOf('image') !== -1) { - appendDraftImage(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 filteredSlashCmds = React.useMemo(() => filterAISlashCommands(slashFilter), [slashFilter]); - const aiContexts = useStore(state => state.aiContexts); const addAIContext = useStore(state => state.addAIContext); const removeAIContext = useStore(state => state.removeAIContext); @@ -111,168 +69,51 @@ export const AIChatInput: React.FC = ({ activeContext, activeContextItems, }), [activeProvider, dynamicModels, loadingModels, activeContext, activeContextItems]); + const { + appendingContext, + contextExpanded, + contextLoading, + contextOpen, + dbList, + filteredTables, + handleAppendContext, + handleDbChange, + handleOpenContext, + handleRemoveContextItem, + searchText, + selectedDbName, + selectedTableKeys, + setContextExpanded, + setContextOpen, + setSearchText, + setSelectedTableKeys, + } = useAIChatContextBinding({ + activeContext, + activeContextItems, + connectionKey, + addAIContext, + removeAIContext, + }); - const fetchTablesForDb = async (dbName: string, connConfig: any) => { - setContextLoading(true); - setSelectedDbName(dbName); - try { - const res = await DBGetTables(buildRpcConnectionConfig(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 { + fileInputRef, + handleImageUpload, + handlePasteImages, + handleRemoveDraftImage, + } = useAIChatDraftImages({ + setDraftImages, + }); - 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(buildRpcConnectionConfig(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(buildRpcConnectionConfig(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 () => { - if (!activeContext?.connectionId) { - return; - } - 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 rpcConfig = buildRpcConnectionConfig(conn.config) as any; - const schemaResult = await resolveAITableSchemaToolResult({ - tableName, - fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName), - fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName), - }); - if (!schemaResult.success) { - message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`); - } - - if (schemaResult.success && schemaResult.content) { - addAIContext(connectionKey, { - dbName: dbName, - tableName: tableName, - ddl: schemaResult.content - }); - 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); - } - }; - - 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) { - appendDraftImage(blob); - } - } - } - }, [appendDraftImage]); - - 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]); + const { + filteredSlashCmds, + handleComposerInputChange, + handleOpenSlashMenu, + handleSelectSlashCommand, + showSlashMenu, + } = useAISlashCommandMenu({ + setInput, + textareaRef, + }); const handleComposerNoticeAction = React.useCallback(() => { if (composerNotice?.action?.key && typeof onComposerAction === 'function') { @@ -284,14 +125,6 @@ export const AIChatInput: React.FC = ({ ? handleComposerNoticeAction : undefined; - 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 (
@@ -485,10 +318,7 @@ export const AIChatInput: React.FC = ({ 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); - }} + onDbChange={handleDbChange} onSearchTextChange={setSearchText} onSelectedTableKeysChange={setSelectedTableKeys} /> @@ -669,10 +499,7 @@ export const AIChatInput: React.FC = ({ 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); - }} + onDbChange={handleDbChange} onSearchTextChange={setSearchText} onSelectedTableKeysChange={setSelectedTableKeys} /> diff --git a/frontend/src/components/ai/AISlashCommandMenu.test.tsx b/frontend/src/components/ai/AISlashCommandMenu.test.tsx index 909b512..a7e1167 100644 --- a/frontend/src/components/ai/AISlashCommandMenu.test.tsx +++ b/frontend/src/components/ai/AISlashCommandMenu.test.tsx @@ -19,10 +19,11 @@ describe('AISlashCommandMenu', () => { expect(markup).toContain('data-ai-chat-slash-empty="true"'); expect(markup).toContain('没有匹配的快捷命令'); - expect(markup).toContain('/query'); + expect(markup).toContain('/sql'); + expect(markup).toContain('/health'); }); - it('renders slash command entries when matches exist', () => { + it('renders grouped slash command entries when matches exist', () => { const markup = renderToStaticMarkup( { label: '生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:', + category: 'generate', }]} darkMode={false} textColor="#162033" @@ -41,6 +43,8 @@ describe('AISlashCommandMenu', () => { expect(markup).toContain('/sql'); expect(markup).toContain('生成 SQL'); + expect(markup).toContain('data-ai-chat-slash-group="generate"'); + expect(markup).toContain('SQL 生成'); expect(markup).not.toContain('没有匹配的快捷命令'); }); }); diff --git a/frontend/src/components/ai/AISlashCommandMenu.tsx b/frontend/src/components/ai/AISlashCommandMenu.tsx index 01b3fe1..690a335 100644 --- a/frontend/src/components/ai/AISlashCommandMenu.tsx +++ b/frontend/src/components/ai/AISlashCommandMenu.tsx @@ -1,11 +1,11 @@ import React from 'react'; -export interface AISlashCommandDefinition { - cmd: string; - label: string; - desc: string; - prompt: string; -} +import { + DEFAULT_AI_SLASH_COMMANDS, + getFeaturedAISlashCommands, + groupAISlashCommands, + type AISlashCommandDefinition, +} from './aiSlashCommands'; interface AISlashCommandMenuProps { visible: boolean; @@ -18,6 +18,18 @@ interface AISlashCommandMenuProps { onSelect: (command: AISlashCommandDefinition) => void; } +const featuredCommands = getFeaturedAISlashCommands(); + +const commandCardStyle = (darkMode: boolean): React.CSSProperties => ({ + padding: '10px 12px', + borderRadius: 10, + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + gap: 4, + transition: 'background 0.15s', +}); + export const AISlashCommandMenu: React.FC = ({ visible, commands, @@ -32,51 +44,84 @@ export const AISlashCommandMenu: React.FC = ({ return null; } + const groups = groupAISlashCommands(commands); + 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} + {groups.length > 0 ? ( +
+ {groups.map((group) => ( +
+
+
{group.title}
+
{group.description}
+
+
+ {group.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` 等内置命令。 + 可以先试这些更常用的入口,快速走到生成 SQL、AI 体检或 MCP 排查。 +
+
+ {featuredCommands.map((command) => ( + + ))} +
+
+ 当前共提供 {DEFAULT_AI_SLASH_COMMANDS.length} 个 slash 命令,支持按命令名、中文说明或关键词搜索。
)} diff --git a/frontend/src/components/ai/aiSlashCommands.test.ts b/frontend/src/components/ai/aiSlashCommands.test.ts new file mode 100644 index 0000000..62060a4 --- /dev/null +++ b/frontend/src/components/ai/aiSlashCommands.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterAISlashCommands, + getFeaturedAISlashCommands, + groupAISlashCommands, +} from './aiSlashCommands'; + +describe('aiSlashCommands', () => { + it('returns all default commands when only slash is present', () => { + const commands = filterAISlashCommands('/'); + + expect(commands.length).toBeGreaterThan(8); + expect(commands.some((command) => command.cmd === '/health')).toBe(true); + expect(commands.some((command) => command.cmd === '/mcp')).toBe(true); + }); + + it('supports filtering by chinese keywords in addition to command prefix', () => { + const commands = filterAISlashCommands('体检'); + + expect(commands.map((command) => command.cmd)).toContain('/health'); + }); + + it('groups commands by configured category order', () => { + const groups = groupAISlashCommands(filterAISlashCommands('/')); + + expect(groups[0]?.key).toBe('generate'); + expect(groups[1]?.key).toBe('review'); + expect(groups[2]?.key).toBe('diagnose'); + }); + + it('keeps featured commands available for empty-state quick picks', () => { + const featured = getFeaturedAISlashCommands().map((command) => command.cmd); + + expect(featured).toContain('/sql'); + expect(featured).toContain('/health'); + expect(featured).toContain('/mcp'); + }); +}); diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index 19b2479..db14732 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -1,20 +1,87 @@ -import type { AISlashCommandDefinition } from './AISlashCommandMenu'; +export type AISlashCommandCategory = 'generate' | 'review' | 'diagnose'; + +export interface AISlashCommandDefinition { + cmd: string; + label: string; + desc: string; + prompt: string; + category: AISlashCommandCategory; + keywords?: string[]; + featured?: boolean; +} + +export interface AISlashCommandCategoryMeta { + key: AISlashCommandCategory; + title: string; + description: string; +} + +export interface AISlashCommandGroup extends AISlashCommandCategoryMeta { + commands: AISlashCommandDefinition[]; +} + +export const AI_SLASH_COMMAND_CATEGORIES: AISlashCommandCategoryMeta[] = [ + { + key: 'generate', + title: 'SQL 生成', + description: '直接产出 SQL、测试数据或迁移草稿。', + }, + { + key: 'review', + title: '结构评审', + description: '解释 SQL、评审表设计和索引策略。', + }, + { + key: 'diagnose', + title: '诊断探针', + description: '优先调用内置探针看 AI、MCP 和最近 SQL 活动的真实状态。', + }, +]; 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 语句:' }, + { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:', category: 'generate', featured: true, keywords: ['查询', '自然语言', '查数据'] }, + { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:', category: 'generate', featured: true, keywords: ['sql', '生成', '查询语句'] }, + { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:', category: 'generate', keywords: ['mock', '测试数据', 'insert'] }, + { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:', category: 'generate', keywords: ['diff', '迁移', 'alter'] }, + { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```', category: 'review', featured: true, keywords: ['解释', 'sql', '逻辑'] }, + { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```', category: 'review', keywords: ['优化', '索引', '性能'] }, + { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:', category: 'review', keywords: ['schema', '表结构', '设计'] }, + { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:', category: 'review', keywords: ['index', '索引', '慢查询'] }, + { cmd: '/health', label: '🩺 AI 配置体检', desc: '调用体检探针总览当前 AI 配置', prompt: '请先调用 inspect_ai_setup_health,对当前 GoNavi AI 配置做一次完整体检,然后总结 blockers、warnings 和 nextActions。', category: 'diagnose', featured: true, keywords: ['health', '体检', 'ai配置', '探针'] }, + { cmd: '/mcp', label: '🪛 排查 MCP 接入', desc: '检查 MCP 服务和外部客户端状态', prompt: '请先调用 inspect_mcp_setup,帮我盘点当前 MCP 服务、工具发现结果,以及 Claude Code / Codex 的接入状态。', category: 'diagnose', featured: true, keywords: ['mcp', 'codex', 'claude', '外部客户端'] }, + { cmd: '/safety', label: '🛡️ 查看写入安全', desc: '确认只读/写入边界和 allowMutating', prompt: '请先调用 inspect_ai_safety,告诉我当前 AI 和 GoNavi MCP 的写入边界、是否只读,以及 execute_sql 是否需要 allowMutating。', category: 'diagnose', keywords: ['安全', '只读', 'allowmutating', 'ddl', 'dml'] }, + { cmd: '/activity', label: '🕘 最近 SQL 活动', desc: '总结最近执行、报错和热点', prompt: '请先调用 inspect_recent_sql_activity,帮我总结最近 SQL 活动、错误热点和主要读写类型。', category: 'diagnose', keywords: ['activity', 'sql日志', '最近执行', '报错'] }, ]; +const buildCommandSearchText = (command: AISlashCommandDefinition): string => [ + command.cmd, + command.label, + command.desc, + ...(command.keywords || []), +].join(' ').toLowerCase(); + export const filterAISlashCommands = (filter: string): AISlashCommandDefinition[] => { const normalized = String(filter || '').trim().toLowerCase(); - if (!normalized) { + if (!normalized || normalized === '/') { return DEFAULT_AI_SLASH_COMMANDS; } - return DEFAULT_AI_SLASH_COMMANDS.filter((command) => command.cmd.startsWith(normalized)); + + const slashSearch = normalized.startsWith('/') ? normalized : `/${normalized}`; + const keywordSearch = normalized.startsWith('/') ? normalized.slice(1) : normalized; + + return DEFAULT_AI_SLASH_COMMANDS.filter((command) => { + const searchText = buildCommandSearchText(command); + return command.cmd.startsWith(slashSearch) || searchText.includes(keywordSearch); + }); }; + +export const groupAISlashCommands = (commands: AISlashCommandDefinition[]): AISlashCommandGroup[] => + AI_SLASH_COMMAND_CATEGORIES + .map((meta) => ({ + ...meta, + commands: commands.filter((command) => command.category === meta.key), + })) + .filter((group) => group.commands.length > 0); + +export const getFeaturedAISlashCommands = (): AISlashCommandDefinition[] => + DEFAULT_AI_SLASH_COMMANDS.filter((command) => command.featured); diff --git a/frontend/src/components/ai/useAIChatContextBinding.ts b/frontend/src/components/ai/useAIChatContextBinding.ts new file mode 100644 index 0000000..efc9371 --- /dev/null +++ b/frontend/src/components/ai/useAIChatContextBinding.ts @@ -0,0 +1,208 @@ +import React from 'react'; +import { message } from 'antd'; + +import type { AIContextItem } from '../../types'; +import { useStore } from '../../store'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool'; +import { DBGetColumns, DBGetDatabases, DBGetTables, DBShowCreateTable } from '../../../wailsjs/go/app/App'; + +interface ActiveContextRef { + connectionId?: string | null; + dbName?: string | null; +} + +interface UseAIChatContextBindingParams { + activeContext: ActiveContextRef | null; + activeContextItems: AIContextItem[]; + connectionKey: string; + addAIContext: (connectionKey: string, item: AIContextItem) => void; + removeAIContext: (connectionKey: string, dbName: string, tableName: string) => void; +} + +export const useAIChatContextBinding = ({ + activeContext, + activeContextItems, + connectionKey, + addAIContext, + removeAIContext, +}: UseAIChatContextBindingParams) => { + 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 [dbList, setDbList] = React.useState([]); + const [selectedDbName, setSelectedDbName] = React.useState(''); + const [contextExpanded, setContextExpanded] = React.useState(false); + + const filteredTables = React.useMemo( + () => contextTables.filter((table) => table.name.toLowerCase().includes(searchText.toLowerCase())), + [contextTables, searchText], + ); + + const fetchTablesForDb = React.useCallback(async (dbName: string, connConfig: any) => { + setContextLoading(true); + setSelectedDbName(dbName); + try { + const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName); + if (res.success && Array.isArray(res.data)) { + setContextTables(res.data.map((row) => ({ name: Object.values(row)[0] as string }))); + } else { + message.error(`获取表格失败: ${res.message}`); + setContextTables([]); + } + } catch (error: any) { + message.error(error?.message || '获取表格失败'); + setContextTables([]); + } finally { + setContextLoading(false); + } + }, []); + + const handleOpenContext = React.useCallback(async () => { + if (!activeContext?.connectionId) { + message.warning('请先在左侧选择一个数据库作为所聊上下文'); + return; + } + + const connection = useStore.getState().connections.find((item) => item.id === activeContext.connectionId); + if (!connection) { + return; + } + + setContextOpen(true); + setContextLoading(true); + setSearchText(''); + setSelectedTableKeys(activeContextItems.map((item) => `${item.dbName}::${item.tableName}`)); + + try { + const dbRes = await DBGetDatabases(buildRpcConnectionConfig(connection.config) as any); + if (dbRes.success && Array.isArray(dbRes.data)) { + setDbList(dbRes.data.map((row: any) => Object.values(row)[0] as string)); + } + + const initialDbName = activeContext.dbName || ''; + setSelectedDbName(initialDbName); + const tablesRes = await DBGetTables(buildRpcConnectionConfig(connection.config) as any, initialDbName); + if (tablesRes.success && Array.isArray(tablesRes.data)) { + setContextTables(tablesRes.data.map((row: any) => ({ name: Object.values(row)[0] as string }))); + } else { + setContextTables([]); + } + } catch (error: any) { + message.error(error?.message || '读取上下文表失败'); + } finally { + setContextLoading(false); + } + }, [activeContext, activeContextItems]); + + const handleAppendContext = React.useCallback(async () => { + if (!activeContext?.connectionId) { + return; + } + + const connection = useStore.getState().connections.find((item) => item.id === activeContext.connectionId); + if (!connection) { + return; + } + + setAppendingContext(true); + try { + let addedCount = 0; + let removedCount = 0; + + for (const item of activeContextItems) { + const key = `${item.dbName}::${item.tableName}`; + if (!selectedTableKeys.includes(key)) { + removeAIContext(connectionKey, item.dbName, item.tableName); + removedCount += 1; + } + } + + for (const key of selectedTableKeys) { + const [dbName, tableName] = key.split('::'); + if (!dbName || !tableName) { + continue; + } + + if (activeContextItems.some((item) => item.dbName === dbName && item.tableName === tableName)) { + continue; + } + + const rpcConfig = buildRpcConnectionConfig(connection.config) as any; + const schemaResult = await resolveAITableSchemaToolResult({ + tableName, + fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName), + fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName), + }); + + if (!schemaResult.success) { + message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`); + continue; + } + + if (schemaResult.content) { + addAIContext(connectionKey, { + dbName, + tableName, + ddl: schemaResult.content, + }); + addedCount += 1; + } + } + + 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 (error: any) { + message.error(error?.message || '同步 AI 上下文失败'); + } finally { + setAppendingContext(false); + } + }, [activeContext, activeContextItems, addAIContext, connectionKey, removeAIContext, selectedTableKeys]); + + const handleDbChange = React.useCallback((value: string) => { + const connection = useStore.getState().connections.find((item) => item.id === activeContext?.connectionId); + if (connection) { + void fetchTablesForDb(value, connection.config); + } + }, [activeContext?.connectionId, fetchTablesForDb]); + + const handleRemoveContextItem = React.useCallback((dbName: string, tableName: string) => { + removeAIContext(connectionKey, dbName, tableName); + }, [connectionKey, removeAIContext]); + + return { + appendingContext, + contextExpanded, + contextLoading, + contextOpen, + dbList, + filteredTables, + handleAppendContext, + handleDbChange, + handleOpenContext, + handleRemoveContextItem, + searchText, + selectedDbName, + selectedTableKeys, + setContextExpanded, + setContextOpen, + setSearchText, + setSelectedTableKeys, + }; +}; diff --git a/frontend/src/components/ai/useAIChatDraftImages.ts b/frontend/src/components/ai/useAIChatDraftImages.ts new file mode 100644 index 0000000..4ef1378 --- /dev/null +++ b/frontend/src/components/ai/useAIChatDraftImages.ts @@ -0,0 +1,60 @@ +import React from 'react'; + +interface UseAIChatDraftImagesParams { + setDraftImages: React.Dispatch>; +} + +export const useAIChatDraftImages = ({ + setDraftImages, +}: UseAIChatDraftImagesParams) => { + const fileInputRef = React.useRef(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 = React.useCallback((event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + files.forEach((file) => { + if (file.type.includes('image')) { + appendDraftImage(file); + } + }); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [appendDraftImage]); + + const handlePasteImages = React.useCallback((event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) { + return; + } + for (let index = 0; index < items.length; index += 1) { + if (items[index].type.includes('image')) { + event.preventDefault(); + const blob = items[index].getAsFile(); + if (blob) { + appendDraftImage(blob); + } + } + } + }, [appendDraftImage]); + + const handleRemoveDraftImage = React.useCallback((index: number) => { + setDraftImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); + }, [setDraftImages]); + + return { + fileInputRef, + handleImageUpload, + handlePasteImages, + handleRemoveDraftImage, + }; +}; diff --git a/frontend/src/components/ai/useAISlashCommandMenu.ts b/frontend/src/components/ai/useAISlashCommandMenu.ts new file mode 100644 index 0000000..a947131 --- /dev/null +++ b/frontend/src/components/ai/useAISlashCommandMenu.ts @@ -0,0 +1,61 @@ +import React from 'react'; + +import { filterAISlashCommands, type AISlashCommandDefinition } from './aiSlashCommands'; + +interface UseAISlashCommandMenuParams { + setInput: (val: string) => void; + textareaRef: React.RefObject; +} + +export const useAISlashCommandMenu = ({ + setInput, + textareaRef, +}: UseAISlashCommandMenuParams) => { + const [showSlashMenu, setShowSlashMenu] = React.useState(false); + const [slashFilter, setSlashFilter] = React.useState(''); + + const filteredSlashCmds = React.useMemo( + () => filterAISlashCommands(slashFilter), + [slashFilter], + ); + + const handleComposerInputChange = React.useCallback((value: string) => { + setInput(value); + if (value.startsWith('/')) { + setSlashFilter(value.split(/\s/u)[0] || '/'); + setShowSlashMenu(true); + return; + } + 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]); + + const hideSlashMenu = React.useCallback(() => { + setShowSlashMenu(false); + setSlashFilter(''); + }, []); + + return { + filteredSlashCmds, + handleComposerInputChange, + handleOpenSlashMenu, + handleSelectSlashCommand, + hideSlashMenu, + showSlashMenu, + slashFilter, + }; +};