574 lines
22 KiB
TypeScript
574 lines
22 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
||
import { produce } from 'immer';
|
||
import { useBoolean, useGetState } from 'ahooks';
|
||
import { sendChatMessage, updateFeedback, generateConversationName } from '../services/api.client';
|
||
import type { ChatItem, Feedbacktype, ThoughtItem, VisionFile, MessageEnd, MessageReplace } from '../types/dify_chat';
|
||
import { CHAT_CONFIG } from '../config/chat';
|
||
|
||
/**
|
||
* 聊天消息处理钩子
|
||
*/
|
||
export default function useChatMessage({
|
||
onUpdateConversationList,
|
||
onConversationIdChange,
|
||
}: {
|
||
onUpdateConversationList?: (conversationId: string, data: { name: string }) => void;
|
||
onConversationIdChange?: (conversationId: string) => void;
|
||
}) {
|
||
// 聊天消息列表
|
||
const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>([]);
|
||
// 是否正在响应中
|
||
const [isResponding, { setTrue: setResponding, setFalse: setNotResponding }] = useBoolean(false);
|
||
// 是否已停止响应
|
||
const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false);
|
||
// 响应的会话是否为当前会话
|
||
const [isRespondingConIsCurrCon, setIsRespondingConCurrCon, getIsRespondingConIsCurrCon] = useGetState(true);
|
||
// 消息任务ID
|
||
const [messageTaskId, setMessageTaskId] = useState('');
|
||
// 用户查询
|
||
const [userQuery, setUserQuery] = useState('');
|
||
// 中止控制器
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
|
||
// 当前流式响应项的引用
|
||
const currentResponseRef = useRef<ChatItem | null>(null);
|
||
|
||
/**
|
||
* 记录错误日志
|
||
*/
|
||
const logError = useCallback((message: string) => {
|
||
console.error(`[Chat Error]: ${message}`);
|
||
}, []);
|
||
|
||
/**
|
||
* 检查是否可以发送消息
|
||
*/
|
||
const checkCanSend = useCallback(() => {
|
||
if (isResponding) {
|
||
console.warn('机器人正在回复中,请稍后再试');
|
||
return false;
|
||
}
|
||
return true;
|
||
}, [isResponding]);
|
||
|
||
/**
|
||
* 立即更新聊天列表 - 移除防抖机制
|
||
*/
|
||
const updateChatList = useCallback((
|
||
responseItem: ChatItem,
|
||
questionId: string,
|
||
placeholderAnswerId: string,
|
||
questionItem: ChatItem,
|
||
originalResponseId?: string
|
||
) => {
|
||
// console.log('🔄 更新聊天列表:', {
|
||
// responseItemId: responseItem.id,
|
||
// responseContent: responseItem.content,
|
||
// originalResponseId,
|
||
// questionId,
|
||
// placeholderAnswerId
|
||
// });
|
||
|
||
setChatList(produce(getChatList(), (draft) => {
|
||
// console.log('📝 当前聊天列表:', draft.map(item => ({ id: item.id, content: item.content.substring(0, 20), isAnswer: item.isAnswer })));
|
||
|
||
// 移除占位符
|
||
const placeholderIndex = draft.findIndex(item => item.id === placeholderAnswerId);
|
||
if (placeholderIndex !== -1) {
|
||
// console.log('🗑️ 移除占位符:', placeholderAnswerId);
|
||
draft.splice(placeholderIndex, 1);
|
||
}
|
||
|
||
// 确保问题存在
|
||
const questionIndex = draft.findIndex(item => item.id === questionId);
|
||
if (questionIndex === -1) {
|
||
// console.log('➕ 添加问题:', questionId);
|
||
draft.push({ ...questionItem });
|
||
}
|
||
|
||
// 更新或添加响应 - 考虑ID可能已经改变的情况
|
||
let responseIndex = draft.findIndex(item => item.id === responseItem.id);
|
||
// console.log('🔍 查找响应索引 (当前ID):', { responseItemId: responseItem.id, responseIndex });
|
||
|
||
// 如果找不到当前ID的响应,尝试查找原始ID
|
||
if (responseIndex === -1 && originalResponseId) {
|
||
responseIndex = draft.findIndex(item => item.id === originalResponseId);
|
||
// console.log('🔍 查找响应索引 (原始ID):', { originalResponseId, responseIndex });
|
||
}
|
||
|
||
// 如果找不到任何匹配的响应,查找最后一个AI回答
|
||
if (responseIndex === -1) {
|
||
responseIndex = draft.findIndex((item, index) =>
|
||
item.isAnswer &&
|
||
index > draft.findIndex(q => q.id === questionId)
|
||
);
|
||
// console.log('🔍 查找响应索引 (最后AI回答):', { responseIndex });
|
||
}
|
||
|
||
if (responseIndex !== -1) {
|
||
// console.log('✏️ 更新现有响应:', { responseIndex, newContent: responseItem.content.substring(0, 20) });
|
||
draft[responseIndex] = { ...responseItem };
|
||
} else {
|
||
// console.log('➕ 添加新响应:', { responseId: responseItem.id, content: responseItem.content.substring(0, 20) });
|
||
draft.push({ ...responseItem });
|
||
}
|
||
|
||
// console.log('📝 更新后聊天列表:', draft.map(item => ({ id: item.id, content: item.content.substring(0, 20), isAnswer: item.isAnswer })));
|
||
}));
|
||
}, [getChatList, setChatList]);
|
||
|
||
/**
|
||
* 更新当前问答 - 移除防抖,立即更新
|
||
*/
|
||
const updateCurrentQA = useCallback(({
|
||
responseItem,
|
||
questionId,
|
||
placeholderAnswerId,
|
||
questionItem,
|
||
originalResponseId,
|
||
}: {
|
||
responseItem: ChatItem;
|
||
questionId: string;
|
||
placeholderAnswerId: string;
|
||
questionItem: ChatItem;
|
||
originalResponseId?: string;
|
||
}) => {
|
||
// 更新当前响应引用
|
||
currentResponseRef.current = responseItem;
|
||
|
||
// 立即更新,不使用防抖
|
||
updateChatList(responseItem, questionId, placeholderAnswerId, questionItem, originalResponseId);
|
||
}, [updateChatList]);
|
||
|
||
/**
|
||
* 转换文件格式为服务器格式
|
||
*/
|
||
const transformToServerFile = useCallback((fileItem: any) => {
|
||
return {
|
||
type: 'image',
|
||
transfer_method: fileItem.transferMethod || fileItem.transfer_method,
|
||
url: fileItem.url,
|
||
upload_file_id: fileItem.id || fileItem.upload_file_id,
|
||
};
|
||
}, []);
|
||
|
||
/**
|
||
* 发送消息
|
||
*/
|
||
const handleSend = useCallback(async (
|
||
message: string,
|
||
conversationId: string | null,
|
||
files?: VisionFile[],
|
||
inputs?: Record<string, any>,
|
||
) => {
|
||
if (!checkCanSend() || !message.trim()) {
|
||
return;
|
||
}
|
||
|
||
// console.log('📤 发送消息:', { message, conversationId });
|
||
|
||
setUserQuery(message);
|
||
setResponding();
|
||
setHasStopResponded(false);
|
||
setIsRespondingConCurrCon(true);
|
||
|
||
// 处理输入参数
|
||
const toServerInputs: Record<string, any> = {};
|
||
if (inputs) {
|
||
Object.keys(inputs).forEach((key) => {
|
||
const value = inputs[key];
|
||
if (value?.supportFileType) {
|
||
toServerInputs[key] = transformToServerFile(value);
|
||
} else if (Array.isArray(value) && value[0]?.supportFileType) {
|
||
toServerInputs[key] = value.map((item: any) => transformToServerFile(item));
|
||
} else {
|
||
toServerInputs[key] = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 准备请求数据
|
||
const data: Record<string, any> = {
|
||
inputs: toServerInputs,
|
||
query: message,
|
||
conversation_id: conversationId === '-1' ? null : conversationId,
|
||
};
|
||
|
||
// 添加文件数据
|
||
if (files && files.length > 0) {
|
||
data.files = files.map((item) => {
|
||
if (item.transfer_method === 'local_file') {
|
||
return {
|
||
...item,
|
||
url: '',
|
||
};
|
||
}
|
||
return item;
|
||
});
|
||
}
|
||
|
||
// 生成唯一ID
|
||
const questionId = `question-${Date.now()}`;
|
||
const placeholderAnswerId = `answer-placeholder-${Date.now()}`;
|
||
|
||
// 创建问题项
|
||
const questionItem: ChatItem = {
|
||
id: questionId,
|
||
content: message,
|
||
isAnswer: false,
|
||
message_files: files,
|
||
};
|
||
|
||
// 创建答案占位符
|
||
const placeholderAnswerItem: ChatItem = {
|
||
id: placeholderAnswerId,
|
||
content: '',
|
||
isAnswer: true,
|
||
};
|
||
|
||
// 更新聊天列表
|
||
const newList = [...getChatList(), questionItem, placeholderAnswerItem];
|
||
setChatList(newList);
|
||
|
||
let isAgentMode = false;
|
||
let hasSetResponseId = false;
|
||
const prevTempNewConversationId = conversationId || '-1';
|
||
let tempNewConversationId = '';
|
||
|
||
// 创建响应项
|
||
const responseItem: ChatItem = {
|
||
id: `response-${Date.now()}`,
|
||
content: '',
|
||
agent_thoughts: [],
|
||
message_files: [],
|
||
isAnswer: true,
|
||
};
|
||
|
||
// 保存原始响应ID
|
||
const originalResponseId = responseItem.id;
|
||
|
||
try {
|
||
// 发送消息
|
||
await sendChatMessage(data, {
|
||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }) => {
|
||
// console.log('📨 收到流式数据:', { message, isFirstMessage, messageId, newConversationId });
|
||
|
||
if (!isAgentMode) {
|
||
// 累积消息内容
|
||
responseItem.content = responseItem.content + message;
|
||
// console.log('📝 累积消息内容:', { currentContent: responseItem.content });
|
||
} else {
|
||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1];
|
||
if (lastThought) {
|
||
lastThought.thought = (lastThought.thought || '') + message;
|
||
}
|
||
}
|
||
|
||
if (messageId && !hasSetResponseId) {
|
||
responseItem.id = messageId;
|
||
hasSetResponseId = true;
|
||
// console.log('🆔 设置响应ID:', { oldId: originalResponseId, newId: messageId });
|
||
}
|
||
|
||
// 重要:确保正确获取新会话ID
|
||
if (newConversationId && !tempNewConversationId) {
|
||
tempNewConversationId = newConversationId;
|
||
// console.log('🆔 获取到新会话ID:', tempNewConversationId);
|
||
}
|
||
|
||
setMessageTaskId(taskId || '');
|
||
|
||
// 检查是否切换到其他会话
|
||
// console.log('🔍 会话检查:', {
|
||
// prevTempNewConversationId,
|
||
// conversationId,
|
||
// isEqual: prevTempNewConversationId === conversationId
|
||
// });
|
||
|
||
// 修复新会话的匹配逻辑
|
||
const isNewConversationMatch = (prevTempNewConversationId === '-1' && conversationId === null) ||
|
||
(prevTempNewConversationId === conversationId);
|
||
|
||
if (!isNewConversationMatch) {
|
||
// console.log('⚠️ 会话不匹配,跳过更新');
|
||
setIsRespondingConCurrCon(false);
|
||
return;
|
||
}
|
||
|
||
// console.log('🔄 准备调用updateCurrentQA:', {
|
||
// responseItemId: responseItem.id,
|
||
// responseContent: responseItem.content,
|
||
// questionId,
|
||
// placeholderAnswerId,
|
||
// originalResponseId
|
||
// });
|
||
|
||
// 更新当前问答(使用防抖)
|
||
updateCurrentQA({
|
||
responseItem: { ...responseItem }, // 创建副本避免引用问题
|
||
questionId,
|
||
placeholderAnswerId,
|
||
questionItem,
|
||
originalResponseId,
|
||
});
|
||
},
|
||
|
||
onCompleted: async (hasError?: boolean) => {
|
||
// console.log('✅ 消息发送完成:', { hasError });
|
||
|
||
// 立即更新最终状态
|
||
if (currentResponseRef.current) {
|
||
updateCurrentQA({
|
||
responseItem: { ...currentResponseRef.current },
|
||
questionId,
|
||
placeholderAnswerId,
|
||
questionItem,
|
||
originalResponseId,
|
||
});
|
||
}
|
||
|
||
if (hasError) {
|
||
setNotResponding();
|
||
return;
|
||
}
|
||
|
||
// 如果是新会话,处理会话ID更新和名称生成
|
||
// 检查原始传入的conversationId是否为新会话标识
|
||
const isNewConversation = conversationId === '-1' || conversationId === null;
|
||
if (tempNewConversationId && isNewConversation) {
|
||
try {
|
||
// console.log('🆕 处理新会话:', {
|
||
// tempNewConversationId,
|
||
// originalConversationId: conversationId,
|
||
// isNewConversation
|
||
// });
|
||
|
||
// 通知会话ID变更(这会触发localStorage更新)
|
||
onConversationIdChange?.(tempNewConversationId);
|
||
|
||
// 生成会话名称
|
||
const res = await generateConversationName(tempNewConversationId);
|
||
const { data } = res as any;
|
||
if (data?.name) {
|
||
// console.log('📝 生成会话名称:', data.name);
|
||
onUpdateConversationList?.(tempNewConversationId, { name: data.name });
|
||
}
|
||
} catch (err) {
|
||
console.error('生成会话名称失败:', err);
|
||
}
|
||
} else {
|
||
// console.log('🔍 不是新会话,跳过处理:', {
|
||
// tempNewConversationId,
|
||
// conversationId,
|
||
// isNewConversation
|
||
// });
|
||
}
|
||
|
||
setNotResponding();
|
||
setUserQuery('');
|
||
},
|
||
|
||
onFile: (file) => {
|
||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1];
|
||
if (lastThought) {
|
||
lastThought.message_files = [...(lastThought.message_files || []), { ...file }];
|
||
} else {
|
||
if (!responseItem.message_files) {
|
||
responseItem.message_files = [];
|
||
}
|
||
responseItem.message_files.push(file);
|
||
}
|
||
|
||
updateCurrentQA({
|
||
responseItem: { ...responseItem },
|
||
questionId,
|
||
placeholderAnswerId,
|
||
questionItem,
|
||
originalResponseId,
|
||
});
|
||
},
|
||
|
||
onThought: (thought) => {
|
||
isAgentMode = true;
|
||
|
||
if (thought.message_id && !hasSetResponseId) {
|
||
responseItem.id = thought.message_id;
|
||
hasSetResponseId = true;
|
||
}
|
||
|
||
if (!responseItem.agent_thoughts) {
|
||
responseItem.agent_thoughts = [];
|
||
}
|
||
|
||
if (responseItem.agent_thoughts.length === 0) {
|
||
responseItem.agent_thoughts.push(thought);
|
||
} else {
|
||
const lastThought = responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1];
|
||
// 相同思考ID,更新现有思考
|
||
if (lastThought.id === thought.id) {
|
||
thought.thought = lastThought.thought;
|
||
thought.message_files = lastThought.message_files;
|
||
responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = thought;
|
||
} else {
|
||
responseItem.agent_thoughts.push(thought);
|
||
}
|
||
}
|
||
|
||
// 检查是否切换到其他会话
|
||
if (prevTempNewConversationId !== conversationId) {
|
||
setIsRespondingConCurrCon(false);
|
||
return;
|
||
}
|
||
|
||
updateCurrentQA({
|
||
responseItem: { ...responseItem },
|
||
questionId,
|
||
placeholderAnswerId,
|
||
questionItem,
|
||
originalResponseId,
|
||
});
|
||
},
|
||
|
||
onMessageEnd: (messageEnd: MessageEnd) => {
|
||
// 处理消息结束事件
|
||
// console.log('Message ended:', messageEnd);
|
||
},
|
||
|
||
onMessageReplace: (messageReplace: MessageReplace) => {
|
||
// 处理消息替换事件
|
||
responseItem.content = messageReplace.answer;
|
||
updateCurrentQA({
|
||
responseItem: { ...responseItem },
|
||
questionId,
|
||
placeholderAnswerId,
|
||
questionItem,
|
||
originalResponseId,
|
||
});
|
||
},
|
||
|
||
onError: (error) => {
|
||
logError(`聊天消息请求错误: ${error}`);
|
||
setChatList(produce(getChatList(), (draft) => {
|
||
const placeholderIndex = draft.findIndex(item => item.id === placeholderAnswerId);
|
||
if (placeholderIndex !== -1) {
|
||
draft[placeholderIndex].content = `错误: ${error}`;
|
||
draft[placeholderIndex].isError = true;
|
||
}
|
||
}));
|
||
setNotResponding();
|
||
},
|
||
|
||
getAbortController: (controller) => {
|
||
abortControllerRef.current = controller;
|
||
},
|
||
});
|
||
} catch (err: any) {
|
||
logError(`发送消息时出错: ${err.message}`);
|
||
setNotResponding();
|
||
}
|
||
}, [
|
||
checkCanSend,
|
||
getChatList,
|
||
setChatList,
|
||
logError,
|
||
onUpdateConversationList,
|
||
onConversationIdChange,
|
||
setNotResponding,
|
||
setResponding,
|
||
updateCurrentQA,
|
||
transformToServerFile,
|
||
setHasStopResponded,
|
||
setIsRespondingConCurrCon,
|
||
setMessageTaskId,
|
||
setUserQuery
|
||
]);
|
||
|
||
/**
|
||
* 处理反馈
|
||
*/
|
||
const handleFeedback = useCallback(async (messageId: string, feedback: Feedbacktype) => {
|
||
try {
|
||
await updateFeedback({
|
||
url: `messages/${messageId}/feedbacks`,
|
||
body: feedback,
|
||
});
|
||
|
||
// 更新聊天列表中的反馈
|
||
setChatList(produce(getChatList(), (draft) => {
|
||
const messageIndex = draft.findIndex(item => item.id === messageId);
|
||
if (messageIndex !== -1) {
|
||
draft[messageIndex].feedback = feedback;
|
||
}
|
||
}));
|
||
} catch (err) {
|
||
logError(`提交反馈时出错: ${err}`);
|
||
}
|
||
}, [logError, getChatList, setChatList]);
|
||
|
||
/**
|
||
* 停止响应
|
||
*/
|
||
const stopResponding = useCallback(() => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
abortControllerRef.current = null;
|
||
}
|
||
|
||
setHasStopResponded(true);
|
||
setNotResponding();
|
||
}, [setNotResponding, setHasStopResponded]);
|
||
|
||
/**
|
||
* 重新生成回答
|
||
*/
|
||
const regenerateResponse = useCallback(async (messageId: string) => {
|
||
// 找到要重新生成的消息
|
||
const messageIndex = getChatList().findIndex(item => item.id === messageId);
|
||
if (messageIndex === -1) return;
|
||
|
||
const message = getChatList()[messageIndex];
|
||
if (!message.isAnswer) return;
|
||
|
||
// 找到对应的问题
|
||
const questionIndex = messageIndex - 1;
|
||
if (questionIndex < 0) return;
|
||
|
||
const question = getChatList()[questionIndex];
|
||
if (question.isAnswer) return;
|
||
|
||
// 重新发送问题
|
||
await handleSend(question.content, null, question.message_files);
|
||
}, [getChatList, handleSend]);
|
||
|
||
/**
|
||
* 清空聊天记录
|
||
*/
|
||
const clearChatList = useCallback(() => {
|
||
setChatList([]);
|
||
currentResponseRef.current = null;
|
||
}, [setChatList]);
|
||
|
||
return {
|
||
// 基础状态
|
||
chatList,
|
||
setChatList,
|
||
getChatList,
|
||
isResponding,
|
||
hasStopResponded,
|
||
isRespondingConIsCurrCon,
|
||
messageTaskId,
|
||
userQuery,
|
||
|
||
// 核心方法
|
||
handleSend,
|
||
handleFeedback,
|
||
stopResponding,
|
||
regenerateResponse,
|
||
clearChatList,
|
||
|
||
// 辅助方法
|
||
checkCanSend,
|
||
logError,
|
||
updateCurrentQA,
|
||
};
|
||
}
|