| | |
| | | |
| | | interface Message { |
| | | role: 'user' | 'assistant'; |
| | | content: string; |
| | | content: string | null; |
| | | timestamp: number; |
| | | id: string; |
| | | conversation_id?: string; |
| | |
| | | // 添加一个新的状态来控制消息的显示 |
| | | const [showMessages, setShowMessages] = useState(false); |
| | | |
| | | // 在组件顶部增加一个强制更新计数器 |
| | | const [forceUpdateCounter, setForceUpdateCounter] = useState(0); |
| | | |
| | | // 添加状态来控制思考内容的显示/隐藏 |
| | | const [expandedThinkMessages, setExpandedThinkMessages] = useState<Record<string, boolean>>({}); |
| | | |
| | | // 在组件顶部添加显示/隐藏密码的状态 |
| | | const [showApiKey, setShowApiKey] = useState(false); |
| | | |
| | | const messagesEndRef = useRef<HTMLDivElement>(null); |
| | | const errorTimeoutRef = useRef<any>(null); |
| | | |
| | | // 在组件顶部添加一个引用,用于跟踪组件是否已卸载 |
| | | const isMountedRef = useRef(true); |
| | | |
| | | useEffect(() => { |
| | | // 获取存储的API Key |
| | |
| | | } |
| | | }; |
| | | return cleanup; |
| | | }, []); |
| | | |
| | | // 添加组件卸载时的清理工作 |
| | | useEffect(() => { |
| | | // 组件挂载时,设置为true |
| | | isMountedRef.current = true; |
| | | |
| | | // 组件卸载时,设置为false |
| | | return () => { |
| | | isMountedRef.current = false; |
| | | |
| | | // 清除所有定时器 |
| | | if (errorTimeoutRef.current) { |
| | | clearTimeout(errorTimeoutRef.current); |
| | | } |
| | | }; |
| | | }, []); |
| | | |
| | | const handleApiKeySubmit = () => { |
| | |
| | | setCurrentMessageId(assistantMessage.id); |
| | | setMessage(''); |
| | | |
| | | let controller: AbortController | null = new AbortController(); |
| | | |
| | | try { |
| | | const response = await fetch(`${BASE_URL}/v1/chat-messages`, { |
| | | method: 'POST', |
| | |
| | | conversation_id: conversationId || '', |
| | | user: 'abc-123', |
| | | files: [] |
| | | }) |
| | | }), |
| | | signal: controller.signal // 添加中止信号 |
| | | }); |
| | | |
| | | console.log("API响应状态:", response.status, response.statusText); |
| | | |
| | | if (!response.ok) { |
| | | const errorText = await response.text(); |
| | | console.error("API错误响应:", errorText); |
| | | let errorMessage; |
| | | try { |
| | | const errorJson = JSON.parse(errorText); |
| | | errorMessage = errorJson.error || `Error: ${response.status}`; |
| | | errorMessage = errorJson.error || `错误: ${response.status}`; |
| | | } catch { |
| | | errorMessage = `Error: ${response.status}`; |
| | | errorMessage = `错误: ${response.status}`; |
| | | } |
| | | throw new Error(errorMessage); |
| | | } |
| | | |
| | | const reader = response.body?.getReader(); |
| | | if (!reader) throw new Error('No reader available'); |
| | | // 确保response.body存在 |
| | | if (!response.body) { |
| | | throw new Error('响应没有提供数据流'); |
| | | } |
| | | |
| | | const reader = response.body.getReader(); |
| | | const decoder = new TextDecoder(); |
| | | let buffer = ''; |
| | | |
| | | while (true) { |
| | | const { done, value } = await reader.read(); |
| | | if (done) { |
| | | setIsMessageComplete(true); |
| | | break; |
| | | } |
| | | try { |
| | | const { done, value } = await reader.read(); |
| | | if (done) { |
| | | console.log("流式响应接收完毕"); |
| | | setIsMessageComplete(true); |
| | | break; |
| | | } |
| | | |
| | | buffer += decoder.decode(value, { stream: true }); |
| | | const lines = buffer.split('\n'); |
| | | buffer = lines.pop() || ''; |
| | | const chunk = decoder.decode(value, { stream: true }); |
| | | console.log("接收到数据块:", chunk); |
| | | buffer += chunk; |
| | | const lines = buffer.split('\n'); |
| | | buffer = lines.pop() || ''; |
| | | |
| | | for (const line of lines) { |
| | | if (line.trim() === '') continue; |
| | | if (line.startsWith('data: ')) { |
| | | try { |
| | | const data = JSON.parse(line.slice(6)); |
| | | |
| | | // 忽略ping事件 |
| | | if (data.event === 'ping') continue; |
| | | for (const line of lines) { |
| | | if (line.trim() === '') continue; |
| | | |
| | | console.log("处理行数据:", line); |
| | | |
| | | if (line.startsWith('data: ')) { |
| | | try { |
| | | const jsonStr = line.slice(6); |
| | | console.log("完整原始数据:", line); |
| | | console.log("解析JSON字符串:", jsonStr); |
| | | const data = JSON.parse(jsonStr); |
| | | console.log("解析后的数据对象:", data); |
| | | |
| | | // 忽略ping事件 |
| | | if (data.event === 'ping') { |
| | | console.log("忽略ping事件"); |
| | | continue; |
| | | } |
| | | |
| | | switch (data.event) { |
| | | case 'message': |
| | | setMessages(prev => { |
| | | const newMessages = [...prev]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role === 'assistant' && lastMessage.id === currentMessageId) { |
| | | lastMessage.content = data.answer || lastMessage.content; |
| | | if (data.message_id) { |
| | | lastMessage.id = data.message_id; |
| | | setCurrentMessageId(data.message_id); |
| | | switch (data.event) { |
| | | case 'message': |
| | | console.log(`收到message事件:`, data); |
| | | |
| | | // 提取message数据 |
| | | const messageId = data.message_id; |
| | | const answerChunk = data.answer || ''; |
| | | const convId = data.conversation_id; |
| | | |
| | | // 如果不在循环中,直接调用更新函数 |
| | | handleMessageChunk(messageId, answerChunk, convId); |
| | | break; |
| | | |
| | | case 'message_end': |
| | | console.log('收到message_end事件:', data); |
| | | |
| | | // 检查是否有最终答案 |
| | | if (data.metadata && data.id) { |
| | | setIsMessageComplete(true); |
| | | setConversationId(data.conversation_id || null); |
| | | } |
| | | break; |
| | | |
| | | case 'workflow_finished': |
| | | console.log('工作流完成:', data); |
| | | |
| | | // 检查工作流输出是否有答案 |
| | | if (data.data && data.data.outputs && data.data.outputs.answer) { |
| | | // 如果工作流有最终答案,更新消息内容 |
| | | const finalAnswer = data.data.outputs.answer; |
| | | const messageId = data.message_id; |
| | | |
| | | if (finalAnswer) { |
| | | console.log('从工作流获取最终答案:', finalAnswer); |
| | | handleFinalAnswer(messageId, finalAnswer, data.conversation_id); |
| | | } |
| | | } |
| | | return newMessages; |
| | | }); |
| | | break; |
| | | |
| | | case 'message_end': |
| | | if (data.conversation_id) { |
| | | setConversationId(data.conversation_id); |
| | | setMessages(prev => { |
| | | const newMessages = [...prev]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role === 'assistant' && lastMessage.id === currentMessageId) { |
| | | lastMessage.conversation_id = data.conversation_id; |
| | | if (data.metadata) { |
| | | lastMessage.metadata = data.metadata; |
| | | } |
| | | break; |
| | | |
| | | case 'node_finished': |
| | | // 检查节点类型是否为answer节点 |
| | | if (data.data && data.data.node_type === 'answer' && data.data.outputs && data.data.outputs.answer) { |
| | | console.log('从answer节点获取答案:', data.data.outputs.answer); |
| | | const answer = data.data.outputs.answer; |
| | | if (answer) { |
| | | handleFinalAnswer(data.message_id, answer, data.conversation_id); |
| | | } |
| | | return newMessages; |
| | | }); |
| | | } |
| | | setIsMessageComplete(true); |
| | | break; |
| | | |
| | | case 'error': |
| | | console.error('Error event received:', data); |
| | | setIsMessageComplete(true); |
| | | throw new Error(data.message || '发送消息时出错'); |
| | | } |
| | | } catch (e) { |
| | | console.error('Error parsing SSE data:', e); |
| | | if (!messages[messages.length - 1]?.content) { |
| | | } |
| | | break; |
| | | |
| | | // 处理其他工作流事件 |
| | | case 'workflow_started': |
| | | case 'node_started': |
| | | // 这些事件只需记录,不需要更新UI |
| | | console.log(`工作流事件 ${data.event}:`, data); |
| | | break; |
| | | |
| | | case 'error': |
| | | console.error('服务器返回错误事件:', data); |
| | | setIsMessageComplete(true); |
| | | throw new Error(data.message || '发送消息时出错'); |
| | | } |
| | | } catch (e) { |
| | | console.error('解析SSE数据出错:', e, '原始行:', line); |
| | | setIsMessageComplete(true); |
| | | throw e; |
| | | } |
| | | } |
| | | } |
| | | } catch (err) { |
| | | console.error('处理流式响应时出错:', err); |
| | | throw err; |
| | | } |
| | | } |
| | | } catch (err) { |
| | | console.error('Chat error:', err); |
| | | showErrorMessage(err instanceof Error ? err.message : '发送消息时出错'); |
| | | // 检查是否是中止错误 |
| | | if (err instanceof Error && err.name === 'AbortError') { |
| | | console.log('请求被中止,可能是组件卸载导致的'); |
| | | return; // 中止错误不需要显示给用户 |
| | | } |
| | | |
| | | setMessages(prev => { |
| | | const newMessages = [...prev]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role === 'assistant') { |
| | | lastMessage.content = '抱歉,消息发送失败,请稍后重试。'; |
| | | } |
| | | return newMessages; |
| | | }); |
| | | console.error('聊天请求错误:', err); |
| | | let errorMsg = err instanceof Error ? err.message : '发送消息时出错'; |
| | | |
| | | // 只有在组件仍然挂载时才更新UI |
| | | if (isMountedRef.current) { |
| | | showErrorMessage(errorMsg); |
| | | |
| | | setMessages(prev => { |
| | | const newMessages = [...prev]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role === 'assistant') { |
| | | lastMessage.content = `抱歉,无法获取回复: ${errorMsg}`; |
| | | } |
| | | return newMessages; |
| | | }); |
| | | } |
| | | } finally { |
| | | setIsStreaming(false); |
| | | // 清除控制器 |
| | | controller = null; |
| | | |
| | | // 只有在组件仍然挂载时才更新UI |
| | | if (isMountedRef.current) { |
| | | setIsStreaming(false); |
| | | setIsMessageComplete(true); |
| | | } |
| | | } |
| | | }; |
| | | |
| | | // 修改handleMessageChunk函数,添加组件挂载检查 |
| | | const handleMessageChunk = useCallback((messageId: string, answerChunk: string, convId?: string) => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return; |
| | | |
| | | console.log(`处理消息片段: ID=${messageId}, 片段="${answerChunk}"`); |
| | | |
| | | // 使用函数式更新确保获取最新状态 |
| | | setMessages(prevMessages => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return prevMessages; |
| | | |
| | | // 创建消息数组的拷贝 |
| | | const newMessages = [...prevMessages]; |
| | | |
| | | // 查找最后一条助手消息 |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | if (lastMessage?.role !== 'assistant') { |
| | | console.warn('找不到助手消息来更新'); |
| | | return prevMessages; // 不需要更新 |
| | | } |
| | | |
| | | // 更新消息内容和ID |
| | | const updatedContent = (lastMessage.content || '') + answerChunk; |
| | | |
| | | // 创建消息的新副本以确保React检测到变化 |
| | | const updatedMessage = { |
| | | ...lastMessage, |
| | | id: messageId, |
| | | content: updatedContent, |
| | | timestamp: Date.now() |
| | | }; |
| | | |
| | | if (convId) { |
| | | updatedMessage.conversation_id = convId; |
| | | } |
| | | |
| | | // 更新当前正在处理的消息ID |
| | | if (isMountedRef.current) { |
| | | setCurrentMessageId(messageId); |
| | | } |
| | | |
| | | // 替换最后一条消息 |
| | | newMessages[newMessages.length - 1] = updatedMessage; |
| | | |
| | | console.log(`更新后的内容长度: ${updatedContent.length}`); |
| | | |
| | | // 触发强制更新 |
| | | if (isMountedRef.current) { |
| | | setTimeout(() => { |
| | | if (isMountedRef.current) { |
| | | setForceUpdateCounter(count => count + 1); |
| | | } |
| | | }, 0); |
| | | } |
| | | |
| | | return newMessages; |
| | | }); |
| | | }, []); |
| | | |
| | | // 修改handleFinalAnswer函数,添加组件挂载检查 |
| | | const handleFinalAnswer = useCallback((messageId: string, answer: string, convId?: string) => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return; |
| | | |
| | | console.log(`设置最终答案: ID=${messageId}, 内容="${answer}"`); |
| | | |
| | | setMessages(prevMessages => { |
| | | // 如果组件已卸载,则不执行更新 |
| | | if (!isMountedRef.current) return prevMessages; |
| | | |
| | | const newMessages = [...prevMessages]; |
| | | const lastMessage = newMessages[newMessages.length - 1]; |
| | | |
| | | if (lastMessage?.role !== 'assistant') { |
| | | console.warn('找不到助手消息来更新最终答案'); |
| | | return prevMessages; |
| | | } |
| | | |
| | | // 创建消息的新副本,设置最终答案 |
| | | const updatedMessage = { |
| | | ...lastMessage, |
| | | id: messageId, |
| | | content: answer, |
| | | timestamp: Date.now() |
| | | }; |
| | | |
| | | if (convId) { |
| | | updatedMessage.conversation_id = convId; |
| | | } |
| | | |
| | | // 更新状态 |
| | | if (isMountedRef.current) { |
| | | setIsMessageComplete(true); |
| | | setCurrentMessageId(messageId); |
| | | } |
| | | |
| | | // 替换最后一条消息 |
| | | newMessages[newMessages.length - 1] = updatedMessage; |
| | | |
| | | // 触发强制更新 |
| | | if (isMountedRef.current) { |
| | | setTimeout(() => { |
| | | if (isMountedRef.current) { |
| | | setForceUpdateCounter(count => count + 1); |
| | | } |
| | | }, 0); |
| | | } |
| | | |
| | | return newMessages; |
| | | }); |
| | | }, []); |
| | | |
| | | // 更新发送按钮的禁用状态 |
| | | const isSendDisabled = isStreaming || !message.trim() || !isMessageComplete; |
| | |
| | | setShowMessages(true); |
| | | }, 100); |
| | | return () => clearTimeout(timer); |
| | | }, []); |
| | | |
| | | // 添加自定义CSS动画样式 |
| | | useEffect(() => { |
| | | const style = document.createElement('style'); |
| | | style.innerHTML = ` |
| | | @keyframes dotBounce { |
| | | 0%, 80%, 100% { transform: translateY(0); } |
| | | 40% { transform: translateY(-6px); } |
| | | } |
| | | .dot-animation span:nth-child(1) { animation: dotBounce 1.4s -0.32s infinite ease-in-out; } |
| | | .dot-animation span:nth-child(2) { animation: dotBounce 1.4s -0.16s infinite ease-in-out; } |
| | | .dot-animation span:nth-child(3) { animation: dotBounce 1.4s 0s infinite ease-in-out; } |
| | | `; |
| | | document.head.appendChild(style); |
| | | |
| | | return () => { |
| | | document.head.removeChild(style); |
| | | }; |
| | | }, []); |
| | | |
| | | // 添加处理思考内容的辅助函数 |
| | | const parseMessageContent = (content: string | null) => { |
| | | if (!content) return { mainContent: '', thinkContent: null }; |
| | | |
| | | // 匹配<think>标签内容 (不区分大小写) |
| | | const thinkMatch = content.match(/<think>([\s\S]*?)<\/think>/i); |
| | | |
| | | if (thinkMatch) { |
| | | // 提取思考内容 |
| | | const thinkContent = thinkMatch[1].trim(); |
| | | // 移除<think>标签,保留主要内容 |
| | | const mainContent = content.replace(/<think>[\s\S]*?<\/think>/i, '').trim(); |
| | | return { mainContent, thinkContent }; |
| | | } |
| | | |
| | | return { mainContent: content, thinkContent: null }; |
| | | }; |
| | | |
| | | // 切换思考内容的显示/隐藏 |
| | | const toggleThinkContent = (messageId: string) => { |
| | | setExpandedThinkMessages(prev => ({ |
| | | ...prev, |
| | | [messageId]: !prev[messageId] |
| | | })); |
| | | }; |
| | | |
| | | // 添加组件卸载时的清理函数 |
| | | useEffect(() => { |
| | | return () => { |
| | | // 组件卸载时的清理工作 |
| | | console.log('聊天组件卸载,清理资源'); |
| | | }; |
| | | }, []); |
| | | |
| | | return ( |
| | |
| | | </button> |
| | | </div> |
| | | <div className="space-y-2"> |
| | | <input |
| | | type="password" |
| | | value={apiKey} |
| | | onChange={(e) => setApiKey(e.target.value)} |
| | | placeholder="请输入您的 API Key" |
| | | className="w-full px-4 py-2 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100/50" |
| | | /> |
| | | <div className="relative"> |
| | | <input |
| | | type={showApiKey ? "text" : "password"} |
| | | value={apiKey} |
| | | onChange={(e) => setApiKey(e.target.value)} |
| | | placeholder="请输入您的 API Key" |
| | | className="w-full px-4 py-2 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100/50" |
| | | /> |
| | | <button |
| | | type="button" |
| | | onClick={() => setShowApiKey(!showApiKey)} |
| | | className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" |
| | | > |
| | | {showApiKey ? ( |
| | | // 眼睛关闭图标 |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" /> |
| | | <path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" /> |
| | | </svg> |
| | | ) : ( |
| | | // 眼睛图标 |
| | | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| | | <path d="M10 12a2 2 0 100-4 2 2 0 000 4z" /> |
| | | <path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" /> |
| | | </svg> |
| | | )} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <div className="flex justify-end gap-2"> |
| | | <button |
| | |
| | | <div className="flex-1 overflow-y-auto pt-20 pb-32"> |
| | | <div className="max-w-4xl mx-auto px-6"> |
| | | <div className={`space-y-6 ${showMessages ? 'opacity-100' : 'opacity-0'} transition-opacity duration-200`}> |
| | | {messages.map((msg, index) => ( |
| | | <div |
| | | key={msg.id} |
| | | className={`flex items-start gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`} |
| | | > |
| | | <div className="relative flex-shrink-0"> |
| | | <div className="w-8 h-8 rounded-lg overflow-hidden shadow-inner bg-gray-50"> |
| | | <Image |
| | | src={msg.role === 'assistant' ? "/images/logo.jpg" : DEFAULT_USER_AVATAR} |
| | | alt={msg.role === 'assistant' ? "AI助手" : "用户"} |
| | | width={32} |
| | | height={32} |
| | | className="w-full h-full object-cover" |
| | | /> |
| | | </div> |
| | | {msg.role === 'assistant' && ( |
| | | <div className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-400 rounded-full border-2 border-white"></div> |
| | | )} |
| | | </div> |
| | | <div className={`flex-1 min-w-0 ${msg.role === 'user' ? 'text-right' : ''}`}> |
| | | <div className={`${ |
| | | msg.role === 'assistant' |
| | | ? 'bg-white rounded-xl shadow-sm border border-gray-100' |
| | | : 'bg-[#E8F4FF] rounded-xl' |
| | | } inline-block max-w-[85%] relative min-h-[42px]`}> |
| | | {msg.role === 'assistant' && !isMessageComplete && currentMessageId === msg.id && ( |
| | | <div className="absolute left-0 top-0 w-full h-full flex items-center justify-center"> |
| | | <div className="w-5 h-5 border-2 border-blue-500/30 border-t-blue-500/80 rounded-full animate-spin"></div> |
| | | </div> |
| | | )} |
| | | <div className={`flex items-center px-4 min-h-[42px] ${ |
| | | msg.role === 'assistant' && !isMessageComplete && currentMessageId === msg.id ? 'invisible' : '' |
| | | }`}> |
| | | <div className="prose prose-sm max-w-none text-[14px] leading-[1.3] |
| | | prose-p:my-0 prose-p:leading-[1.3] |
| | | prose-headings:font-medium prose-headings:text-gray-800 |
| | | prose-h1:text-lg prose-h1:my-2 |
| | | prose-h2:text-base prose-h2:my-2 |
| | | prose-h3:text-base prose-h3:my-1.5 |
| | | prose-ul:my-1.5 prose-ul:pl-4 prose-li:my-0.5 |
| | | prose-ol:my-1.5 prose-ol:pl-4 |
| | | prose-code:px-1 prose-code:py-0.5 prose-code:bg-gray-100 prose-code:rounded prose-code:text-gray-800 prose-code:before:content-[''] prose-code:after:content-[''] |
| | | prose-pre:my-2 prose-pre:p-2.5 prose-pre:bg-gray-800 prose-pre:rounded-lg |
| | | prose-a:text-blue-500 prose-a:no-underline hover:prose-a:underline |
| | | prose-blockquote:my-2 prose-blockquote:pl-3 prose-blockquote:border-l-4 prose-blockquote:border-gray-200 |
| | | prose-strong:font-medium prose-strong:text-gray-800 |
| | | prose-table:my-2 prose-tr:border-gray-200 prose-td:py-1 prose-td:px-2"> |
| | | {msg.content.split('\n').map((line, i) => ( |
| | | <div key={i} className="my-0"> |
| | | {line.trim() && ( |
| | | <ReactMarkdown |
| | | remarkPlugins={[remarkGfm]} |
| | | rehypePlugins={[rehypeRaw, rehypeSanitize]} |
| | | > |
| | | {line.trim()} |
| | | </ReactMarkdown> |
| | | )} |
| | | </div> |
| | | ))} |
| | | </div> |
| | | {messages.length === 0 ? ( |
| | | <div className="flex items-center justify-center h-[200px] text-gray-400"> |
| | | <p>发送消息开始对话</p> |
| | | </div> |
| | | ) : ( |
| | | messages.map((msg, index) => ( |
| | | <div |
| | | key={msg.id} |
| | | className={`flex items-start gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`} |
| | | > |
| | | <div className="relative flex-shrink-0"> |
| | | <div className="w-8 h-8 rounded-lg overflow-hidden shadow-inner bg-gray-50"> |
| | | <Image |
| | | src={msg.role === 'assistant' ? "/images/logo.jpg" : DEFAULT_USER_AVATAR} |
| | | alt={msg.role === 'assistant' ? "AI助手" : "用户"} |
| | | width={32} |
| | | height={32} |
| | | className="w-full h-full object-cover" |
| | | /> |
| | | </div> |
| | | </div> |
| | | <div className="mt-0.5 text-xs text-gray-400"> |
| | | {msg.role === 'user' && new Date(msg.timestamp).toLocaleTimeString()} |
| | | <div className={`flex-1 min-w-0 ${msg.role === 'user' ? 'text-right' : ''}`}> |
| | | <div className={`${ |
| | | msg.role === 'assistant' |
| | | ? 'bg-white rounded-xl shadow-sm border border-gray-100' |
| | | : 'bg-[#E8F4FF] rounded-xl' |
| | | } inline-block max-w-[85%] relative overflow-hidden`}> |
| | | |
| | | {/* 调试信息 - 移除这部分 */} |
| | | {/* |
| | | {process.env.NODE_ENV !== 'production' && ( |
| | | <div className="bg-gray-100 px-2 py-1 text-xs text-gray-500"> |
| | | 长度: {(msg.content || '').length} | 计数: {forceUpdateCounter} |
| | | </div> |
| | | )} |
| | | */} |
| | | |
| | | {msg.role === 'assistant' && ( |
| | | <>{(() => { |
| | | // 解析消息内容,提取思考部分 |
| | | const { mainContent, thinkContent } = parseMessageContent(msg.content); |
| | | const isExpanded = expandedThinkMessages[msg.id] || false; |
| | | const thinkingDuration = 8; // 思考时间(秒) |
| | | |
| | | return ( |
| | | <> |
| | | {/* 思考内容区域 (如果存在) */} |
| | | {thinkContent && ( |
| | | <div className="border-b border-gray-200"> |
| | | <button |
| | | onClick={() => toggleThinkContent(msg.id)} |
| | | className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700" |
| | | > |
| | | <div className="flex items-center space-x-2"> |
| | | <span>已深度思考 (用时 {thinkingDuration} 秒)</span> |
| | | </div> |
| | | <svg |
| | | className={`w-4 h-4 transform transition-transform ${isExpanded ? 'rotate-180' : ''}`} |
| | | fill="none" |
| | | stroke="currentColor" |
| | | viewBox="0 0 24 24" |
| | | > |
| | | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> |
| | | </svg> |
| | | </button> |
| | | |
| | | {/* 可折叠的思考内容 */} |
| | | {isExpanded && ( |
| | | <div className="bg-gray-50 px-4 py-3 text-sm text-gray-700 border-t border-gray-200 whitespace-pre-wrap"> |
| | | {thinkContent} |
| | | </div> |
| | | )} |
| | | </div> |
| | | )} |
| | | |
| | | {/* 主要内容 */} |
| | | <div className="p-3"> |
| | | <div className="text-gray-800 leading-relaxed whitespace-pre-wrap"> |
| | | {mainContent || (msg.role === 'assistant' && !isMessageComplete ? '正在思考...' : '')} |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 加载指示器 */} |
| | | {msg.role === 'assistant' && !isMessageComplete && ( |
| | | <div className="absolute bottom-1 right-2"> |
| | | <div className="flex space-x-1"> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-0"></div> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-150"></div> |
| | | <div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse delay-300"></div> |
| | | </div> |
| | | </div> |
| | | )} |
| | | </> |
| | | ); |
| | | })()}</> |
| | | )} |
| | | |
| | | {/* 用户消息简单显示 */} |
| | | {msg.role === 'user' && ( |
| | | <div className="p-3"> |
| | | <div className="text-gray-800 leading-relaxed whitespace-pre-wrap"> |
| | | {msg.content} |
| | | </div> |
| | | </div> |
| | | )} |
| | | </div> |
| | | <div className="mt-0.5 text-xs text-gray-400"> |
| | | {msg.role === 'user' && new Date(msg.timestamp).toLocaleTimeString()} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | ))} |
| | | )) |
| | | )} |
| | | <div ref={messagesEndRef} /> |
| | | </div> |
| | | </div> |