Files
leaudit-platform-frontend/app/components/dify-chat/index.tsx
T
2025-11-30 19:27:01 +08:00

613 lines
22 KiB
TypeScript

import { useBoolean, useGetState } from 'ahooks';
import { Layout, theme } from 'antd';
import { useEffect, useRef, useState } from 'react';
import ChatInput from './chat-input';
import ChatMessage from './chat-message';
import ChatSidebar, { type ChatSidebarRef } from './sidebar';
// import Header from '../layout/Header';
import type { ChatItem, ConversationItem } from '~/api/dify-chat';
import { fetchAppParams, fetchChatList, fetchConversations } from '~/api/dify-chat';
import { CHAT_CONFIG } from '../../config/chat';
import useChatMessage from '../../hooks/use-chat-message';
import useConversation from '../../hooks/use-conversation';
import '../../styles/components/chat-with-llm/index.css';
const { Content } = Layout;
/**
* 主聊天组件
* 实现单页面应用模式,参考webapp-conversation的初始化逻辑
*/
export default function Chat() {
// 侧边栏状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
// 会话管理
const {
conversationList,
setConversationList,
currConversationId,
getCurrConversationId,
setCurrConversationId,
getConversationIdFromStorage,
isNewConversation,
currInputs,
newConversationInputs,
resetNewConversationInputs,
setCurrInputs,
currConversationInfo,
setNewConversationInfo,
setExistConversationInfo,
addConversationToList,
updateConversationInList,
removeConversationFromList,
} = useConversation();
// 消息管理
const {
chatList,
setChatList,
getChatList,
isResponding,
handleSend,
stopResponding,
handleFeedback,
} = useChatMessage({
onUpdateConversationList: updateConversationInList,
onConversationIdChange: async (conversationId: string) => {
console.log('🔄 [Chat] 收到会话ID变更通知:', {
oldConversationId: currConversationId,
newConversationId: conversationId,
willUpdateLocalStorage: true
});
// 设置当前会话ID(这会触发localStorage更新)
setCurrConversationId(conversationId, CHAT_CONFIG.APP_ID);
// 如果是新会话,添加到会话列表
const existingConversation = conversationList.find(item => item.id === conversationId);
if (!existingConversation) {
// console.log('🆕 添加新会话到列表:', conversationId);
const newConversation = {
id: conversationId,
name: '新对话',
inputs: currInputs || {},
introduction: '',
};
addConversationToList(newConversation);
// 检查是否需要自动重命名(新对话的第一条消息)
if (!newConversationFirstMessageSent.has(conversationId)) {
// console.log('🏷️ 新对话第一条消息,准备自动重命名:', conversationId);
// 标记该对话已发送第一条消息
setNewConversationFirstMessageSent(prev => new Set(prev).add(conversationId));
// 延迟一下确保组件状态已更新
setTimeout(async () => {
try {
if (sidebarRef.current) {
await sidebarRef.current.autoRename(conversationId);
// console.log('✅ 新对话自动重命名完成:', conversationId);
} else {
console.warn('⚠️ 侧边栏引用不可用,无法自动重命名');
}
} catch (error) {
console.error('❌ 新对话自动重命名失败:', error);
}
}, 1000);
}
}
},
});
// 应用状态
const [appUnavailable, setAppUnavailable] = useState<boolean>(false);
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false);
const [inited, setInited] = useState<boolean>(false);
const [promptConfig, setPromptConfig] = useState<any>(null);
// 会话状态管理
const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false);
const [isChatStarted, { setTrue: setChatStarted, setFalse: setChatNotStarted }] = useBoolean(false);
// 聊天列表容器引用
const chatListDomRef = useRef<HTMLDivElement>(null);
// 侧边栏组件引用
const sidebarRef = useRef<ChatSidebarRef>(null);
// 跟踪新对话是否已发送第一条消息
const [newConversationFirstMessageSent, setNewConversationFirstMessageSent] = useState<Set<string>>(new Set());
// 检查应用配置 - 现在客户端通过Remix API routes调用,不需要APP_KEY
// 只检查API_URL是否配置
const hasSetAppConfig = !!CHAT_CONFIG.API_URL;
/**
* 处理开始聊天
*/
const handleStartChat = (inputs: Record<string, any>) => {
createNewChat();
setConversationIdChangeBecauseOfNew(true);
setCurrInputs(inputs);
setChatStarted();
// 解析变量并生成开场白
setChatList(generateNewChatListWithOpenStatement('', inputs));
};
/**
* 创建新聊天
*/
const createNewChat = () => {
setChatList([]);
setChatNotStarted();
resetNewConversationInputs();
};
/**
* 生成带开场白的新聊天列表
*/
const generateNewChatListWithOpenStatement = (introduction?: string, inputs?: Record<string, any> | null): ChatItem[] => {
const newChatList: ChatItem[] = [];
if (introduction) {
newChatList.push({
id: `opening-statement-${Date.now()}`,
content: introduction,
isAnswer: true,
isOpeningStatement: true,
});
}
return newChatList;
};
/**
* 处理会话切换
*/
const handleConversationSwitch = async () => {
if (!inited) {
return;
}
// console.log('🔄 处理会话切换:', { currConversationId, isNewConversation });
// 更新当前会话的输入
let notSyncToStateIntroduction = '';
let notSyncToStateInputs: Record<string, any> | undefined | null = {};
if (!isNewConversation) {
const item = conversationList.find(item => item.id === currConversationId);
notSyncToStateInputs = item?.inputs || {};
setCurrInputs(notSyncToStateInputs as any);
notSyncToStateIntroduction = item?.introduction || '';
setExistConversationInfo({
name: item?.name || '',
introduction: notSyncToStateIntroduction,
});
} else {
notSyncToStateInputs = newConversationInputs;
setCurrInputs(notSyncToStateInputs);
}
// 更新当前会话的聊天列表
if (!isNewConversation && !conversationIdChangeBecauseOfNew && !isResponding) {
try {
// console.log('📨 获取会话历史消息:', currConversationId);
// 调用API获取历史消息
const response = await fetchChatList(currConversationId);
// console.log('📋 历史消息响应:', response);
if (response && (response as any).data) {
const { data: historyMessages } = response as any;
// 生成新的聊天列表,包含开场白
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
// 添加历史消息
historyMessages.forEach((item: any) => {
// 添加用户问题
newChatList.push({
id: `question-${item.id}`,
content: item.query,
isAnswer: false,
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
});
// 添加AI回答
newChatList.push({
id: item.id,
content: item.answer,
agent_thoughts: item.agent_thoughts || [],
feedback: item.feedback,
isAnswer: true,
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
});
});
// console.log('✅ 设置历史聊天列表:', newChatList.length, '条消息');
setChatList(newChatList);
} else {
console.warn('⚠️ 获取历史消息失败或无数据');
// 如果获取失败,至少显示开场白
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
setChatList(newChatList);
}
} catch (error) {
console.error('❌ 获取历史消息失败:', error);
// 如果获取失败,至少显示开场白
const newChatList: ChatItem[] = generateNewChatListWithOpenStatement(notSyncToStateIntroduction, notSyncToStateInputs);
setChatList(newChatList);
}
}
if (isNewConversation && isChatStarted) {
setChatList(generateNewChatListWithOpenStatement());
}
};
/**
* 处理会话ID变化
*/
const handleConversationIdChange = (id: string) => {
// console.log('🔄 会话ID变化:', { id, currentId: currConversationId });
if (id === '-1') {
createNewChat();
setConversationIdChangeBecauseOfNew(true);
} else {
setConversationIdChangeBecauseOfNew(false);
}
// 触发会话切换
setCurrConversationId(id, CHAT_CONFIG.APP_ID);
};
/**
* 检查是否可以发送消息
*/
const checkCanSend = () => {
if (currConversationId !== '-1') {
return true;
}
if (!currInputs || !promptConfig?.prompt_variables) {
return true;
}
const inputLens = Object.values(currInputs).length;
const promptVariablesLens = promptConfig.prompt_variables.length;
const emptyInput = inputLens < promptVariablesLens || Object.values(currInputs).find(v => !v);
if (emptyInput) {
console.error('必填变量值不能为空');
return false;
}
return true;
};
/**
* 处理发送消息
*/
const handleSendMessage = async (message: string, files?: any[]) => {
if (isResponding) {
console.log('正在响应中,请等待...');
return;
}
if (!checkCanSend()) {
return;
}
console.log('📤 [Chat] 发送消息:', {
message: message.substring(0, 50) + (message.length > 50 ? '...' : ''),
currConversationId,
isNewConversation,
willSendConversationId: isNewConversation ? null : currConversationId
});
try {
// 准备输入数据
const toServerInputs: Record<string, any> = {};
if (currInputs) {
Object.keys(currInputs).forEach((key) => {
toServerInputs[key] = currInputs[key];
});
}
// 使用 useChatMessage 钩子的 handleSend 方法
await handleSend(
message,
isNewConversation ? null : currConversationId,
files,
toServerInputs
);
} catch (error) {
console.error('发送消息失败:', error);
}
};
/**
* 处理侧边栏切换
*/
const handleSidebarToggle = () => {
setSidebarCollapsed(!sidebarCollapsed);
};
/**
* 处理会话选择
*/
const handleConversationSelect = (conversationId: string) => {
if (conversationId !== currConversationId) {
setCurrConversationId(conversationId, CHAT_CONFIG.APP_ID);
}
};
/**
* 处理新建会话
*/
const handleNewConversation = () => {
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false);
createNewChat();
};
/**
* 处理会话删除后的状态更新
*/
const handleConversationDeleted = (conversationId: string) => {
// console.log('🗑️ 处理会话删除后的状态更新:', conversationId);
// 如果删除的是当前会话,切换到新会话
if (conversationId === currConversationId) {
handleNewConversation();
}
// 从列表中移除会话
removeConversationFromList(conversationId);
// console.log('✅ 会话删除状态更新完成:', conversationId);
};
/**
* 处理会话重命名后的状态更新
*/
const handleConversationRenamed = (conversationId: string, newName: string) => {
// console.log('✏️ 处理会话重命名后的状态更新:', { conversationId, newName });
// 更新本地会话列表中的名称
updateConversationInList(conversationId, { name: newName });
// console.log('✅ 会话重命名状态更新完成:', conversationId, '->', newName);
};
/**
* 组件初始化 - 参考webapp-conversation的逻辑
*/
useEffect(() => {
if (!hasSetAppConfig) {
console.error('应用配置不完整');
setAppUnavailable(true);
return;
}
(async () => {
try {
// console.log('🚀 开始初始化聊天应用...');
// 并行获取会话列表和应用参数
const [conversationData, appParams] = await Promise.all([
fetchConversations(),
fetchAppParams()
]);
console.log('📋 [Chat] 获取到的数据:', { conversationData, appParams });
// 处理会话数据
const conversations = (conversationData as any).data || [];
console.log('📋 [Chat] 会话列表:', conversations);
if ((conversationData as any).error) {
console.error('❌ [Chat] 获取会话列表失败:', (conversationData as any).error);
throw new Error((conversationData as any).error);
}
// 处理当前会话ID
const _conversationId = getConversationIdFromStorage(CHAT_CONFIG.APP_ID);
const isNotNewConversation = conversations.some((item: ConversationItem) => item.id === _conversationId);
console.log('💾 [Chat] 初始化 - 本地存储的会话ID:', {
conversationId: _conversationId,
isNotNewConversation,
conversationsCount: conversations.length,
conversationIds: conversations.map((c: ConversationItem) => c.id)
});
// 获取新会话信息
const { user_input_form, opening_statement: introduction } = (appParams as any).data || {};
setNewConversationInfo({
name: '新对话',
introduction: introduction || '',
});
// 设置提示配置
setPromptConfig({
prompt_template: '',
prompt_variables: user_input_form || [],
});
// 设置会话列表
setConversationList(conversations);
// 如果存在有效的会话ID,则设置为当前会话
if (isNotNewConversation) {
console.log('🎯 [Chat] 初始化 - 设置当前会话ID:', _conversationId);
setCurrConversationId(_conversationId, CHAT_CONFIG.APP_ID, false);
} else {
// 如果localStorage为空或会话不存在,自动创建新会话
console.log('🆕 [Chat] 初始化 - localStorage为空或会话不存在,创建新会话');
setCurrConversationId('-1', CHAT_CONFIG.APP_ID, false);
}
setInited(true);
// console.log('✅ 聊天应用初始化完成');
} catch (e: any) {
console.error('❌ 初始化失败:', e);
if (e.status === 404) {
setAppUnavailable(true);
} else {
setIsUnknownReason(true);
setAppUnavailable(true);
}
}
})();
}, []);
// 监听会话切换
useEffect(() => {
handleConversationSwitch();
}, [currConversationId, inited]);
// 自动滚动到底部
useEffect(() => {
if (chatListDomRef.current) {
chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight;
}
}, [chatList]);
// 检查屏幕尺寸
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 992);
};
// 初始检查
checkScreenSize();
// 监听窗口大小变化
window.addEventListener('resize', checkScreenSize);
return () => {
window.removeEventListener('resize', checkScreenSize);
};
}, []);
// 如果应用不可用,显示错误页面
if (appUnavailable) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-2"></h2>
<p className="text-gray-600">
{isUnknownReason ? '发生了未知错误,请稍后重试' : '应用配置不正确,请检查配置'}
</p>
</div>
</div>
);
}
// 如果未初始化完成,显示加载状态
if (!inited) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
// 判断是否已设置输入
const hasSetInputs = (() => {
if (!isNewConversation) {
return true;
}
return isChatStarted;
})();
const conversationName = currConversationInfo?.name || '新对话';
const conversationIntroduction = currConversationInfo?.introduction || '';
return (
<Layout style={{ height: '100%', display: 'flex', flexDirection: 'row' }}>
{/* 移动端遮罩层 */}
{!sidebarCollapsed && isMobile && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-[999]"
onClick={handleSidebarToggle}
/>
)}
{/* 侧边栏 */}
<ChatSidebar
ref={sidebarRef}
collapsed={sidebarCollapsed}
onToggle={handleSidebarToggle}
conversations={conversationList}
currentConversationId={currConversationId}
onConversationSelect={handleConversationSelect}
onNewConversation={handleNewConversation}
onConversationDeleted={handleConversationDeleted}
onConversationRenamed={handleConversationRenamed}
/>
{/* 主内容区域 */}
<Layout style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Content
style={{
background: colorBgContainer,
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
{/* 聊天区域 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div
ref={chatListDomRef}
className="h-full overflow-y-auto px-4 py-4 space-y-2"
>
{/* 如果是新会话且未开始聊天,显示欢迎信息 */}
{isNewConversation && !isChatStarted && (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600"></p>
</div>
)}
{/* 聊天消息列表 */}
{chatList.map((item) => (
<ChatMessage
key={item.id}
message={item}
isResponding={isResponding && item.id === chatList[chatList.length - 1]?.id}
onFeedback={handleFeedback}
/>
))}
</div>
</div>
{/* 输入区域 */}
<div className="flex-shrink-0 bg-white">
<ChatInput
onSendMessage={handleSendMessage}
disabled={isResponding}
placeholder="有什么我能帮您的吗?"
onStop={stopResponding}
isResponding={isResponding}
/>
</div>
</Content>
</Layout>
</Layout>
);
}