mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-10 23:11:40 +08:00
✨ feat(ai-chat): 全面升级AI聊天面板并优化交互体验
- 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数 - 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制 - 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调 - 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板
This commit is contained in:
848
frontend/src/components/AIChatPanel.tsx
Normal file
848
frontend/src/components/AIChatPanel.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Button, Tooltip, Select, Drawer, Input } from 'antd';
|
||||
import { CloseOutlined, ClearOutlined, SendOutlined, RobotOutlined, SettingOutlined, UserOutlined, CheckOutlined, CopyOutlined, DatabaseOutlined, HistoryOutlined, DeleteOutlined, PlusOutlined, MenuFoldOutlined, PlayCircleOutlined, EditOutlined, ReloadOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { AIChatMessage } from '../types';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import './AIChatPanel.css';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
darkMode: boolean;
|
||||
bgColor?: string;
|
||||
onClose: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onWidthChange?: (width: number) => void;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
}
|
||||
|
||||
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
const CodeCopyBtn = ({ text }: { text: string }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className="ai-code-copy-btn"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: copied ? 1 : 0.6,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }}
|
||||
>
|
||||
{copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
|
||||
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeRunBtn = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
|
||||
<span
|
||||
className="ai-code-run-btn"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: { sql: text, runImmediately: false } }));
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
<span style={{ marginLeft: 4 }}>插入</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIChatPanel: React.FC<AIChatPanelProps> = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [activeProvider, setActiveProvider] = useState<any>(null);
|
||||
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [panelWidth, setPanelWidth] = useState(width);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const resizeStartX = useRef(0);
|
||||
const resizeStartWidth = useRef(0);
|
||||
|
||||
const aiChatHistory = useStore(state => state.aiChatHistory);
|
||||
const aiChatSessions = useStore(state => state.aiChatSessions);
|
||||
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
|
||||
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
|
||||
const createNewAISession = useStore(state => state.createNewAISession);
|
||||
const deleteAISession = useStore(state => state.deleteAISession);
|
||||
|
||||
const addAIChatMessage = useStore(state => state.addAIChatMessage);
|
||||
const updateAIChatMessage = useStore(state => state.updateAIChatMessage);
|
||||
const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage);
|
||||
const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages);
|
||||
const clearAIChatHistory = useStore(state => state.clearAIChatHistory);
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
const connections = useStore(state => state.connections);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiActiveSessionId) {
|
||||
createNewAISession();
|
||||
}
|
||||
}, [aiActiveSessionId, createNewAISession]);
|
||||
|
||||
const sid = aiActiveSessionId || 'session-fallback';
|
||||
|
||||
const getConnectionName = useCallback(() => {
|
||||
if (!activeContext?.connectionId) return '';
|
||||
const conn = connections.find(c => c.id === activeContext.connectionId);
|
||||
return conn ? conn.name : '';
|
||||
}, [activeContext, connections]);
|
||||
|
||||
const activeConnName = getConnectionName();
|
||||
|
||||
const messages = aiChatHistory[sid] || [];
|
||||
|
||||
// 主题色
|
||||
const textColor = overlayTheme.titleText;
|
||||
const mutedColor = overlayTheme.mutedText;
|
||||
const borderColor = overlayTheme.divider;
|
||||
const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)';
|
||||
const userBubbleBg = overlayTheme.iconBg;
|
||||
const inputWrapperBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
|
||||
const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
|
||||
const quickActionBorder = overlayTheme.sectionBorder;
|
||||
|
||||
// 获取并监听活动 Provider
|
||||
const loadActiveProvider = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service) return;
|
||||
const [provRes, activeRes] = await Promise.all([
|
||||
Service.AIGetProviders?.(),
|
||||
Service.AIGetActiveProvider?.(),
|
||||
]);
|
||||
if (Array.isArray(provRes) && activeRes) {
|
||||
const current = provRes.find((p: any) => p.id === activeRes);
|
||||
setActiveProvider(current || null);
|
||||
}
|
||||
} catch (e) { console.warn('Failed to load active provider', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]);
|
||||
|
||||
// 模型切换
|
||||
const handleModelChange = async (val: string) => {
|
||||
if (!activeProvider) return;
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const payload = { ...activeProvider, model: val };
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
setActiveProvider(payload);
|
||||
} catch (e) { console.warn('Failed to update provider model', e); }
|
||||
};
|
||||
|
||||
// 动态获取模型列表
|
||||
const fetchDynamicModels = useCallback(async () => {
|
||||
try {
|
||||
setLoadingModels(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service) return;
|
||||
const result = await Service.AIListModels?.();
|
||||
if (result?.success && Array.isArray(result.models) && result.models.length > 0) {
|
||||
console.log('[AI Chat] Dynamic models fetched:', result.models.length, 'models. First 10:', result.models.slice(0, 10));
|
||||
setDynamicModels(result.models);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch models', e);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 自动滚动到底部(增加对发送状态的判定,实现完美跟随)
|
||||
useEffect(() => {
|
||||
if (sending) {
|
||||
// 流式输出期间,改用 auto 避免动画累加导致的卡顿漂移
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
|
||||
} else {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
}, [messages, sending]);
|
||||
|
||||
// 面板初次打开时,自动聚焦输入框
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 监听从 QueryEditor 注入的 prompt
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.prompt) {
|
||||
setInput(detail.prompt);
|
||||
// 自动聚焦输入框并调整高度(setInput 不触发 onChange,需手动重算)
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
window.addEventListener('gonavi:ai:inject-prompt', handler);
|
||||
return () => window.removeEventListener('gonavi:ai:inject-prompt', handler);
|
||||
}, []);
|
||||
|
||||
// 流式监听
|
||||
useEffect(() => {
|
||||
const eventName = `ai:stream:${sid}`;
|
||||
let assistantMsgId = '';
|
||||
|
||||
const handler = (data: { content?: string; done?: boolean; error?: string }) => {
|
||||
console.log('[AI Chat] Stream event received:', JSON.stringify(data));
|
||||
if (data.error) {
|
||||
if (assistantMsgId) {
|
||||
updateAIChatMessage(sid, assistantMsgId, {
|
||||
content: `❌ 错误: ${data.error}`,
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
// 尚未创建 assistant 消息时,新建一条错误消息
|
||||
addAIChatMessage(sid, {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: `❌ 错误: ${data.error}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
assistantMsgId = '';
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.content) {
|
||||
if (!assistantMsgId) {
|
||||
assistantMsgId = genId();
|
||||
addAIChatMessage(sid, {
|
||||
id: assistantMsgId,
|
||||
role: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
});
|
||||
} else {
|
||||
const current = useStore.getState().aiChatHistory[sid];
|
||||
const existing = current?.find(m => m.id === assistantMsgId);
|
||||
updateAIChatMessage(sid, assistantMsgId, {
|
||||
content: (existing?.content || '') + data.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
if (assistantMsgId) {
|
||||
updateAIChatMessage(sid, assistantMsgId, { loading: false });
|
||||
}
|
||||
assistantMsgId = '';
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
EventsOn(eventName, handler);
|
||||
console.log('[AI Chat] Listening on event:', eventName);
|
||||
return () => {
|
||||
EventsOff(eventName);
|
||||
};
|
||||
}, [addAIChatMessage, updateAIChatMessage, sid]);
|
||||
|
||||
// ---- 列表滚动逻辑 ----
|
||||
const handleScrollMessages = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
|
||||
setShowScrollBottom(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
const scrollToMessagesBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
// ---- 气泡快捷操作 ----
|
||||
const handleEditMessage = useCallback((msg: AIChatMessage) => {
|
||||
truncateAIChatMessages(sid, msg.id);
|
||||
deleteAIChatMessage(sid, msg.id);
|
||||
setInput(msg.content);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [sid, truncateAIChatMessages, deleteAIChatMessage]);
|
||||
|
||||
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
|
||||
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
|
||||
const aiIndex = historyLocal.findIndex(m => m.id === msg.id);
|
||||
if (aiIndex <= 0) return;
|
||||
|
||||
let lastUserMsgIndex = -1;
|
||||
for (let i = aiIndex - 1; i >= 0; i--) {
|
||||
if (historyLocal[i].role === 'user') {
|
||||
lastUserMsgIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserMsgIndex >= 0) {
|
||||
const userMsg = historyLocal[lastUserMsgIndex];
|
||||
truncateAIChatMessages(sid, userMsg.id); // 保留到该 userInput 后,丢弃之前生成的失败回复
|
||||
setSending(true);
|
||||
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
|
||||
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
await Service.AIChatStream(sid, messagesPayload);
|
||||
} else if (Service?.AIChatSend) {
|
||||
const result = await Service.AIChatSend(messagesPayload);
|
||||
addAIChatMessage(sid, {
|
||||
id: genId(), role: 'assistant',
|
||||
content: result?.success ? result.content : `❌ ${result?.error || '未知错误'}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
setSending(false);
|
||||
} else {
|
||||
setSending(false);
|
||||
}
|
||||
} catch(e: any) {
|
||||
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${e?.message || e}`, timestamp: Date.now() });
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [sid, truncateAIChatMessages, addAIChatMessage]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'; // 回车发送后重置高度
|
||||
textareaRef.current.focus(); // 保持焦点以便连续对话
|
||||
}
|
||||
|
||||
const userMsg: AIChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addAIChatMessage(sid, userMsg);
|
||||
|
||||
// 构建消息列表发给后端
|
||||
const allMessages = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
console.log('[AI Chat] Calling AIChatStream, sessionId:', sid, 'messages:', allMessages.length);
|
||||
await Service.AIChatStream(sid, allMessages);
|
||||
} else if (Service?.AIChatSend) {
|
||||
const result = await Service.AIChatSend(allMessages);
|
||||
|
||||
const assistantMsg: AIChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: result?.success ? result.content : `❌ ${result?.error || '未知错误'}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addAIChatMessage(sid, assistantMsg);
|
||||
setSending(false);
|
||||
} else {
|
||||
const assistantMsg: AIChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: '❌ AI Service 未就绪',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addAIChatMessage(sid, assistantMsg);
|
||||
setSending(false);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const errMsg: AIChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: `❌ 发送失败: ${e?.message || e}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addAIChatMessage(sid, errMsg);
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, messages, addAIChatMessage, sid]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}, [handleSend]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatCancel) {
|
||||
await Service.AIChatCancel(sid);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to stop chat stream', e);
|
||||
}
|
||||
setSending(false);
|
||||
}, [sid]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
createNewAISession();
|
||||
}, [createNewAISession]);
|
||||
|
||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
}, []);
|
||||
|
||||
const quickActions = [
|
||||
{ label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' },
|
||||
{ label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
|
||||
{ label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
|
||||
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
|
||||
];
|
||||
// ---- 拖拽调整宽度 ----
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
resizeStartX.current = e.clientX;
|
||||
resizeStartWidth.current = panelWidth;
|
||||
}, [panelWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// 面板在右侧,鼠标向左移动增大宽度
|
||||
const delta = resizeStartX.current - e.clientX;
|
||||
const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700);
|
||||
setPanelWidth(newWidth);
|
||||
onWidthChange?.(newWidth);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing, onWidthChange]);
|
||||
|
||||
return (
|
||||
<div className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
className={`ai-resize-handle${isResizing ? ' active' : ''}`}
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div className="ai-chat-header-left" style={{ gap: 8 }}>
|
||||
<Tooltip title="历史会话">
|
||||
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={() => setHistoryOpen(true)} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right">
|
||||
<Tooltip title="新对话 (清空当前)">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="AI 设置">
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="ai-chat-messages" onScroll={handleScrollMessages}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
|
||||
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
|
||||
你好,我是 GoNavi AI
|
||||
</div>
|
||||
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
|
||||
我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。
|
||||
</div>
|
||||
<div className="quick-actions">
|
||||
{quickActions.map(action => (
|
||||
<div
|
||||
key={action.label}
|
||||
className="quick-action-btn"
|
||||
style={{
|
||||
background: quickActionBg,
|
||||
borderColor: quickActionBorder,
|
||||
color: textColor,
|
||||
}}
|
||||
onClick={() => setInput(action.prompt)}
|
||||
>
|
||||
{action.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => {
|
||||
const isUser = msg.role === 'user';
|
||||
return (
|
||||
<div key={msg.id} className="ai-ide-message" style={{
|
||||
borderBottom: 'none',
|
||||
padding: '8px 16px',
|
||||
}}>
|
||||
<div style={{
|
||||
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
}}>
|
||||
<div className="ai-ide-message-header" style={{
|
||||
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
|
||||
marginBottom: isUser ? 6 : 10,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
{isUser
|
||||
? <><UserOutlined /> <span>You</span></>
|
||||
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
|
||||
</div>
|
||||
{/* 气泡操作栏 */}
|
||||
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
|
||||
{isUser ? (
|
||||
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
|
||||
<EditOutlined className="ai-action-icon" onClick={() => handleEditMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
|
||||
<ReloadOutlined className="ai-action-icon" onClick={() => handleRetryMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="删除单条消息">
|
||||
<DeleteOutlined className="ai-action-icon" onClick={() => deleteAIChatMessage(sid, msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
|
||||
{isUser ? (
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div className="ai-code-header" style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
fontSize: 12, color: overlayTheme.mutedText
|
||||
}}>
|
||||
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
{match[1] === 'sql' && <CodeRunBtn text={String(children).replace(/\n$/, '')} />}
|
||||
<CodeCopyBtn text={String(children).replace(/\n$/, '')} />
|
||||
</div>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={darkMode ? vscDarkPlus as any : vs as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: 0, borderRadius: 0, background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)' }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{msg.loading && (
|
||||
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{sending && !messages.some(m => m.role === 'assistant' && m.loading) && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '4px 0' }}>
|
||||
<div style={{
|
||||
background: assistantBubbleBg,
|
||||
borderRadius: 12,
|
||||
padding: '12px 16px',
|
||||
maxWidth: '85%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<span style={{ color: mutedColor, fontSize: 13 }}>等待回复</span>
|
||||
<span className="ai-thinking-dots" style={{ display: 'inline-flex', gap: 3 }}>
|
||||
<span style={{ width: 4, height: 4, borderRadius: '50%', background: overlayTheme.iconColor, animation: 'ai-dot-bounce 1.4s infinite ease-in-out', animationDelay: '0s' }} />
|
||||
<span style={{ width: 4, height: 4, borderRadius: '50%', background: overlayTheme.iconColor, animation: 'ai-dot-bounce 1.4s infinite ease-in-out', animationDelay: '0.2s' }} />
|
||||
<span style={{ width: 4, height: 4, borderRadius: '50%', background: overlayTheme.iconColor, animation: 'ai-dot-bounce 1.4s infinite ease-in-out', animationDelay: '0.4s' }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{showScrollBottom && (
|
||||
<div
|
||||
onClick={scrollToMessagesBottom}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 120,
|
||||
right: 20,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: textColor,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
zIndex: 10,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; }}
|
||||
>
|
||||
<DownOutlined style={{ fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
|
||||
<div className="ai-chat-input-wrapper" style={{
|
||||
borderColor: 'transparent',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '8px 4px 8px'
|
||||
}}>
|
||||
<Input.TextArea
|
||||
ref={textareaRef as any}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{activeConnName && (
|
||||
<Tooltip title="当前数据查询上下文">
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
color: overlayTheme.mutedText, cursor: 'default'
|
||||
}}>
|
||||
<DatabaseOutlined style={{ fontSize: 10 }} />
|
||||
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || (dynamicModels.length > 0 ? dynamicModels[0] : activeProvider.models?.[0])}
|
||||
onChange={handleModelChange}
|
||||
onDropdownVisibleChange={(open) => { if (open) fetchDynamicModels(); }}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
dropdownStyle={{ minWidth: 200 }}
|
||||
showSearch
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sending ? (
|
||||
<button
|
||||
className="ai-chat-send-btn ai-chat-stop-btn"
|
||||
onClick={handleStop}
|
||||
title="停止生成"
|
||||
style={{
|
||||
background: 'rgba(255,77,79,0.1)',
|
||||
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
|
||||
width: 26, height: 26, borderRadius: 6, padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="ai-chat-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
title="发送"
|
||||
style={{
|
||||
background: input.trim() ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
|
||||
color: input.trim() ? overlayTheme.iconColor : mutedColor,
|
||||
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: input.trim() ? 'pointer' : 'not-allowed', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 历史对话抽屉 */}
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={false}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
open={historyOpen}
|
||||
getContainer={false}
|
||||
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
|
||||
width={260}
|
||||
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* 侧拉面板头部 */}
|
||||
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}>对话历史</span>
|
||||
<Tooltip title="收起">
|
||||
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={() => setHistoryOpen(false)} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 新建对话按钮 */}
|
||||
<div style={{ padding: '0 12px 16px' }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { createNewAISession(); setHistoryOpen(false); }}
|
||||
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
|
||||
>
|
||||
开启新对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 列表容器 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
|
||||
{aiChatSessions.length === 0 ? (
|
||||
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}>暂无对话记录</div>
|
||||
) : (
|
||||
aiChatSessions.map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`ai-history-item ${sid === session.id ? 'active' : ''}`}
|
||||
onClick={() => { setAIActiveSessionId(session.id); setHistoryOpen(false); }}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
marginBottom: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: sid === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
|
||||
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sid === session.id ? 600 : 'normal' }}>
|
||||
{session.title || '新对话'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
|
||||
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
className="ai-history-delete-btn"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteAISession(session.id);
|
||||
}}
|
||||
style={{ display: sid === session.id ? 'inline-flex' : undefined }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChatPanel;
|
||||
Reference in New Issue
Block a user