mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-chat): 新增诊断类 slash 命令并拆分输入区状态
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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('没有匹配的快捷命令');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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` 等内置命令。
|
||||
可以先试这些更常用的入口,快速走到生成 SQL、AI 体检或 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>
|
||||
)}
|
||||
|
||||
39
frontend/src/components/ai/aiSlashCommands.test.ts
Normal file
39
frontend/src/components/ai/aiSlashCommands.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
208
frontend/src/components/ai/useAIChatContextBinding.ts
Normal file
208
frontend/src/components/ai/useAIChatContextBinding.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
60
frontend/src/components/ai/useAIChatDraftImages.ts
Normal file
60
frontend/src/components/ai/useAIChatDraftImages.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
61
frontend/src/components/ai/useAISlashCommandMenu.ts
Normal file
61
frontend/src/components/ai/useAISlashCommandMenu.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user