feat(ai-chat): 新增诊断类 slash 命令并拆分输入区状态

This commit is contained in:
Syngnat
2026-06-09 05:29:06 +08:00
parent 25fb3502e1
commit 15e0766bbb
8 changed files with 579 additions and 268 deletions

View File

@@ -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<AIChatInputProps> = ({
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<string[]>([]);
const [searchText, setSearchText] = React.useState('');
const [appendingContext, setAppendingContext] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const appendDraftImage = React.useCallback((blob: Blob) => {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}, [setDraftImages]);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach(file => {
if (file.type.indexOf('image') !== -1) {
appendDraftImage(file);
}
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const [dbList, setDbList] = React.useState<string[]>([]);
const [selectedDbName, setSelectedDbName] = React.useState<string>('');
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<AIChatInputProps> = ({
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<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) {
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<AIChatInputProps> = ({
? 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 (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
@@ -485,10 +318,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
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<AIChatInputProps> = ({
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}
/>

View File

@@ -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(
<AISlashCommandMenu
visible
@@ -31,6 +32,7 @@ describe('AISlashCommandMenu', () => {
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('没有匹配的快捷命令');
});
});

View File

@@ -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<AISlashCommandMenuProps> = ({
visible,
commands,
@@ -32,51 +44,84 @@ export const AISlashCommandMenu: React.FC<AISlashCommandMenuProps> = ({
return null;
}
const groups = groupAISlashCommands(commands);
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>
{groups.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 6 }}>
{groups.map((group) => (
<div key={group.key} data-ai-chat-slash-group={group.key} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ padding: '2px 6px 0' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: textColor }}>{group.title}</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5 }}>{group.description}</div>
</div>
<div style={{ display: 'grid', gap: 4 }}>
{group.commands.map((command) => (
<div
key={command.cmd}
style={commandCardStyle(darkMode)}
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)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: textColor, minWidth: 74 }}>{command.cmd}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: textColor }}>{command.label}</span>
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, paddingLeft: 82 }}>{command.desc}</div>
</div>
))}
</div>
</div>
))}
</div>
)) : (
) : (
<div
data-ai-chat-slash-empty="true"
style={{
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 4,
gap: 8,
}}
>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor }}>
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5 }}>
`/query``/sql``/explain``/optimize`
SQLAI MCP
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{featuredCommands.map((command) => (
<button
key={command.cmd}
type="button"
onClick={() => onSelect(command)}
style={{
borderRadius: 999,
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.14)' : 'rgba(15,23,42,0.12)'}`,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.82)',
color: textColor,
fontSize: 11,
padding: '4px 10px',
cursor: 'pointer',
}}
>
{command.cmd}
</button>
))}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5 }}>
{DEFAULT_AI_SLASH_COMMANDS.length} slash
</div>
</div>
)}

View File

@@ -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');
});
});

View File

@@ -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);

View File

@@ -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<string[]>([]);
const [searchText, setSearchText] = React.useState('');
const [appendingContext, setAppendingContext] = React.useState(false);
const [dbList, setDbList] = React.useState<string[]>([]);
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,
};
};

View File

@@ -0,0 +1,60 @@
import React from 'react';
interface UseAIChatDraftImagesParams {
setDraftImages: React.Dispatch<React.SetStateAction<string[]>>;
}
export const useAIChatDraftImages = ({
setDraftImages,
}: UseAIChatDraftImagesParams) => {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const appendDraftImage = React.useCallback((blob: Blob) => {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages((prev) => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}, [setDraftImages]);
const handleImageUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLTextAreaElement>) => {
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,
};
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { filterAISlashCommands, type AISlashCommandDefinition } from './aiSlashCommands';
interface UseAISlashCommandMenuParams {
setInput: (val: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
}
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,
};
};