451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
import {
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
ExclamationCircleOutlined,
|
||
MenuFoldOutlined,
|
||
MenuUnfoldOutlined,
|
||
MessageOutlined,
|
||
MoreOutlined,
|
||
PlusOutlined,
|
||
SearchOutlined,
|
||
} from '@ant-design/icons';
|
||
import { Button, Dropdown, Input, Layout, Menu, Modal, Tooltip, message, theme, Select } from 'antd';
|
||
import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
|
||
import type { ChatApp } from '~/api/dify-chat-apps/types';
|
||
import type { ConversationItem } from '~/api/dify-chat';
|
||
import { deleteConversation, renameConversation } from '~/api/dify-chat';
|
||
import '../../styles/components/chat-with-llm/sidebar.css';
|
||
|
||
const { Sider } = Layout;
|
||
|
||
interface ChatSidebarProps {
|
||
collapsed: boolean;
|
||
onToggle: () => void;
|
||
conversations: ConversationItem[];
|
||
currentConversationId: string;
|
||
onConversationSelect: (conversationId: string) => void;
|
||
onNewConversation: () => void;
|
||
onConversationDeleted?: (conversationId: string) => void;
|
||
// 对话应用相关属性
|
||
chatApps: ChatApp[];
|
||
loadingChatApps: boolean;
|
||
currentChatApp: ChatApp | null;
|
||
onChatAppChange: (appId: string) => void;
|
||
onConversationRenamed?: (conversationId: string, newName: string) => void;
|
||
}
|
||
|
||
// 暴露给父组件的方法接口
|
||
export interface ChatSidebarRef {
|
||
autoRename: (conversationId: string) => Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* 聊天侧边栏组件
|
||
*/
|
||
const ChatSidebar = forwardRef<ChatSidebarRef, ChatSidebarProps>(({
|
||
collapsed,
|
||
onToggle,
|
||
chatApps,
|
||
loadingChatApps,
|
||
currentChatApp,
|
||
onChatAppChange,
|
||
conversations,
|
||
currentConversationId,
|
||
onConversationSelect,
|
||
onNewConversation,
|
||
onConversationDeleted,
|
||
onConversationRenamed,
|
||
}, ref) => {
|
||
const [searchValue, setSearchValue] = useState('');
|
||
const [renameModalVisible, setRenameModalVisible] = useState(false);
|
||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||
const [renamingConversation, setRenamingConversation] = useState<ConversationItem | null>(null);
|
||
const [deletingConversation, setDeletingConversation] = useState<ConversationItem | null>(null);
|
||
const [newName, setNewName] = useState('');
|
||
const [renameLoading, setRenameLoading] = useState(false);
|
||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
const {
|
||
token: { colorBgContainer, borderRadiusLG },
|
||
} = theme.useToken();
|
||
|
||
// 去重:防止后端返回重复的应用数据
|
||
const uniqueChatApps = useMemo(() => {
|
||
const appMap = new Map<string, ChatApp>();
|
||
chatApps.forEach(app => {
|
||
// 如果已经存在相同ID的应用,保留第一个(或检查is_default属性)
|
||
if (!appMap.has(app.app_id)) {
|
||
appMap.set(app.app_id, app);
|
||
} else {
|
||
// 如果重复的是默认应用,保留默认版本
|
||
const existingApp = appMap.get(app.app_id)!;
|
||
if (app.is_default && !existingApp.is_default) {
|
||
appMap.set(app.app_id, app);
|
||
}
|
||
}
|
||
});
|
||
const unique = Array.from(appMap.values());
|
||
console.log('[Sidebar] 应用去重:', {
|
||
originalCount: chatApps.length,
|
||
uniqueCount: unique.length,
|
||
removed: chatApps.length - unique.length
|
||
});
|
||
return unique;
|
||
}, [chatApps]);
|
||
|
||
// 过滤会话列表
|
||
const filteredConversations = conversations.filter(conv =>
|
||
conv.name.toLowerCase().includes(searchValue.toLowerCase())
|
||
);
|
||
|
||
// 处理重命名
|
||
const handleRename = (conv: ConversationItem) => {
|
||
setRenamingConversation(conv);
|
||
setNewName(conv.name);
|
||
setRenameModalVisible(true);
|
||
};
|
||
|
||
// 处理删除会话 - 显示确认Modal
|
||
const handleDeleteClick = (conv: ConversationItem) => {
|
||
setDeletingConversation(conv);
|
||
setDeleteModalVisible(true);
|
||
};
|
||
|
||
// 确认删除会话
|
||
const handleDeleteConfirm = async () => {
|
||
if (!deletingConversation) return;
|
||
|
||
setDeleteLoading(true);
|
||
try {
|
||
// console.log('🗑️ 开始删除会话:', deletingConversation.id);
|
||
|
||
// 调用API删除服务器端的会话
|
||
const response = await deleteConversation(deletingConversation.id);
|
||
// console.log('✅ 服务器端会话删除响应:', response);
|
||
|
||
// 检查响应是否成功
|
||
if (response && (response as any).result === 'success') {
|
||
// console.log('✅ 服务器端会话删除成功');
|
||
message.success('会话删除成功');
|
||
setDeleteModalVisible(false);
|
||
|
||
// 通知父组件会话已删除
|
||
onConversationDeleted?.(deletingConversation.id);
|
||
|
||
// console.log('✅ 会话删除完成:', deletingConversation.id);
|
||
} else {
|
||
throw new Error((response as any)?.error || '删除会话失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 删除会话失败:', error);
|
||
message.error(`删除会话失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
} finally {
|
||
setDeleteLoading(false);
|
||
}
|
||
};
|
||
|
||
// 取消删除
|
||
const handleDeleteCancel = () => {
|
||
setDeleteModalVisible(false);
|
||
setDeletingConversation(null);
|
||
setDeleteLoading(false);
|
||
};
|
||
|
||
// 确认重命名
|
||
const handleRenameConfirm = async () => {
|
||
if (!renamingConversation || !newName.trim()) {
|
||
message.error('请输入有效的会话名称');
|
||
return;
|
||
}
|
||
|
||
if (newName.trim() === renamingConversation.name) {
|
||
setRenameModalVisible(false);
|
||
return;
|
||
}
|
||
|
||
setRenameLoading(true);
|
||
try {
|
||
// console.log('✏️ 开始重命名会话:', { conversationId: renamingConversation.id, newName: newName.trim() });
|
||
|
||
// 调用API重命名服务器端的会话
|
||
const response = await renameConversation(renamingConversation.id, newName.trim(), false);
|
||
// console.log('✅ 服务器端会话重命名响应:', response);
|
||
|
||
// 检查响应是否成功
|
||
if (response && (response as any).name) {
|
||
// console.log('✅ 服务器端会话重命名成功');
|
||
message.success('重命名成功');
|
||
setRenameModalVisible(false);
|
||
|
||
// 通知父组件会话已重命名
|
||
onConversationRenamed?.(renamingConversation.id, (response as any).name);
|
||
|
||
// console.log('✅ 会话重命名完成:', renamingConversation.id, '->', (response as any).name);
|
||
} else {
|
||
throw new Error((response as any)?.error || '重命名会话失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 重命名会话失败:', error);
|
||
message.error(`重命名失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
} finally {
|
||
setRenameLoading(false);
|
||
}
|
||
};
|
||
|
||
// 取消重命名
|
||
const handleRenameCancel = () => {
|
||
setRenameModalVisible(false);
|
||
setRenamingConversation(null);
|
||
setNewName('');
|
||
setRenameLoading(false);
|
||
};
|
||
|
||
// 生成菜单项
|
||
const menuItems = filteredConversations.map(conv => ({
|
||
key: conv.id,
|
||
icon: <MessageOutlined />,
|
||
label: (
|
||
<div className="flex items-center justify-between group">
|
||
<span className="truncate flex-1" title={conv.name}>
|
||
{conv.name}
|
||
</span>
|
||
{!collapsed && (
|
||
<Dropdown
|
||
menu={{
|
||
items: [
|
||
{
|
||
key: 'rename',
|
||
icon: <EditOutlined />,
|
||
label: '重命名',
|
||
onClick: () => handleRename(conv),
|
||
},
|
||
{
|
||
key: 'delete',
|
||
icon: <DeleteOutlined />,
|
||
label: '删除',
|
||
danger: true,
|
||
onClick: () => handleDeleteClick(conv),
|
||
},
|
||
],
|
||
}}
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<MoreOutlined />}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||
onClick={(e) => e.stopPropagation()}
|
||
// style={{ backgroundColor: '#00684A' }}
|
||
/>
|
||
</Dropdown>
|
||
)}
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
autoRename: async (conversationId: string) => {
|
||
try {
|
||
// console.log('🏷️ 开始自动重命名会话为"新对话":', conversationId);
|
||
|
||
// 调用API将会话重命名为固定的"新对话"
|
||
const response = await renameConversation(conversationId, '新对话', false);
|
||
// console.log('✅ 服务器端会话重命名响应:', response);
|
||
|
||
// 检查响应是否成功
|
||
if (response && (response as any).name) {
|
||
// console.log('✅ 服务器端会话重命名成功');
|
||
|
||
// 通知父组件会话已重命名
|
||
onConversationRenamed?.(conversationId, (response as any).name);
|
||
|
||
// console.log('✅ 会话重命名完成:', conversationId, '->', (response as any).name);
|
||
} else {
|
||
throw new Error((response as any)?.error || '重命名会话失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ 重命名会话失败:', error);
|
||
// 重命名失败时不显示错误消息,避免打扰用户
|
||
// console.warn('⚠️ 重命名失败,会话将保持默认名称');
|
||
}
|
||
},
|
||
}));
|
||
|
||
return (
|
||
<Sider
|
||
trigger={null}
|
||
collapsible
|
||
collapsed={collapsed}
|
||
width={280}
|
||
style={{
|
||
background: colorBgContainer,
|
||
borderRight: '1px solid #f0f0f0',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
height: '100%',
|
||
}}
|
||
>
|
||
{/* 侧边栏头部 - 固定在顶部 */}
|
||
<div className="p-4 border-b border-gray-100 flex-shrink-0">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<Button
|
||
type="text"
|
||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||
onClick={onToggle}
|
||
style={{
|
||
fontSize: '16px',
|
||
width: 32,
|
||
height: 32,
|
||
color: 'rgb(0, 104, 74)',
|
||
}}
|
||
/>
|
||
{!collapsed && (
|
||
<Tooltip title="新建对话">
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={onNewConversation}
|
||
size="small"
|
||
>
|
||
新对话
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
|
||
{/* 搜索框 */}
|
||
{/* 对话应用选择器 */}
|
||
{!collapsed && (
|
||
<div className="mb-3">
|
||
<Select
|
||
value={currentChatApp?.app_id}
|
||
onChange={onChatAppChange}
|
||
loading={loadingChatApps}
|
||
className="w-full"
|
||
placeholder="选择对话应用"
|
||
size="small"
|
||
>
|
||
{uniqueChatApps.map(app => (
|
||
<Select.Option key={app.app_id} value={app.app_id}>
|
||
{app.app_name}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{!collapsed && (
|
||
<Input
|
||
placeholder="搜索对话..."
|
||
prefix={<SearchOutlined />}
|
||
value={searchValue}
|
||
onChange={(e) => setSearchValue(e.target.value)}
|
||
allowClear
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 会话列表 - 可滚动区域 */}
|
||
<div className="flex-1 overflow-hidden">
|
||
<div
|
||
className="h-full overflow-y-auto"
|
||
style={{
|
||
scrollbarWidth: 'thin',
|
||
scrollbarColor: '#c1c1c1 #f1f1f1',
|
||
}}
|
||
>
|
||
{!collapsed && filteredConversations.length === 0 && searchValue && (
|
||
<div className="p-4 text-center text-gray-500">
|
||
<MessageOutlined className="text-2xl mb-2" />
|
||
<p>未找到相关对话</p>
|
||
</div>
|
||
)}
|
||
|
||
{!collapsed && conversations.length === 0 && !searchValue && (
|
||
<div className="p-4 text-center text-gray-500">
|
||
<MessageOutlined className="text-2xl mb-2" />
|
||
<p>暂无对话记录</p>
|
||
<Button
|
||
type="link"
|
||
onClick={onNewConversation}
|
||
className="mt-2"
|
||
>
|
||
开始新对话
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<Menu
|
||
mode="inline"
|
||
selectedKeys={[currentConversationId]}
|
||
items={menuItems}
|
||
onClick={({ key }) => onConversationSelect(key)}
|
||
style={{
|
||
border: 'none',
|
||
background: 'transparent',
|
||
}}
|
||
className="chat-sidebar-menu"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 侧边栏底部 - 固定在底部 */}
|
||
{!collapsed && conversations.length > 0 && (
|
||
<div className="sidebar-footer">
|
||
<div className="stats-text">
|
||
共 {conversations.length} 个对话
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 重命名Modal */}
|
||
<Modal
|
||
title="重命名会话"
|
||
open={renameModalVisible}
|
||
onOk={handleRenameConfirm}
|
||
onCancel={handleRenameCancel}
|
||
confirmLoading={renameLoading}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
destroyOnClose
|
||
>
|
||
<div className="py-4">
|
||
<Input
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
placeholder="请输入新的会话名称"
|
||
maxLength={10}
|
||
showCount
|
||
onPressEnter={handleRenameConfirm}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 删除确认Modal */}
|
||
<Modal
|
||
title={
|
||
<div className="flex items-center">
|
||
<ExclamationCircleOutlined className="text-red-500 mr-2" />
|
||
删除会话
|
||
</div>
|
||
}
|
||
open={deleteModalVisible}
|
||
onOk={handleDeleteConfirm}
|
||
onCancel={handleDeleteCancel}
|
||
confirmLoading={deleteLoading}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
okType="danger"
|
||
destroyOnClose
|
||
>
|
||
<div className="py-4">
|
||
<p>确定要删除会话 <strong>"{deletingConversation?.name}"</strong> 吗?</p>
|
||
<p className="text-gray-500 text-sm mt-2">此操作不可撤销,会话中的所有消息都将被永久删除。</p>
|
||
</div>
|
||
</Modal>
|
||
</Sider>
|
||
);
|
||
});
|
||
|
||
export default ChatSidebar;
|