feat(ai-chat): 全面升级AI聊天面板并优化交互体验

- 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数
- 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制
- 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调
- 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板
This commit is contained in:
Syngnat
2026-03-22 20:54:29 +08:00
parent 36a57f9601
commit 1bda751ada
35 changed files with 6745 additions and 132 deletions

View 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;