Files
MyGoNavi/frontend/src/components/AIChatPanel.tsx
Syngnat 1bda751ada feat(ai-chat): 全面升级AI聊天面板并优化交互体验
- 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数
- 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制
- 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调
- 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板
2026-03-22 20:54:29 +08:00

849 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;