Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { ChatMessage, MessageSender, MessagePurpose } from '../types'; | |
| import { Lightbulb, MessageSquareText, UserCircle, Zap, AlertTriangle, Copy, Check, MoreHorizontal } from 'lucide-react'; | |
| import { marked } from 'marked'; | |
| import DOMPurify from 'dompurify'; | |
| interface SenderIconProps { | |
| sender: MessageSender; | |
| purpose: MessagePurpose; | |
| messageText: string; | |
| } | |
| const SenderIcon: React.FC<SenderIconProps> = ({ sender, purpose, messageText }) => { | |
| const iconClass = "w-5 h-5 mr-2 flex-shrink-0"; | |
| switch (sender) { | |
| case MessageSender.User: | |
| return <UserCircle className={`${iconClass} text-blue-400`} />; | |
| case MessageSender.Cognito: | |
| return <Lightbulb className={`${iconClass} text-green-400`} />; | |
| case MessageSender.Muse: | |
| return <Zap className={`${iconClass} text-purple-400`} />; | |
| case MessageSender.System: | |
| if ( | |
| purpose === MessagePurpose.SystemNotification && | |
| (messageText.toLowerCase().includes("error") || | |
| messageText.toLowerCase().includes("错误") || | |
| messageText.toLowerCase().includes("警告")) | |
| ) { | |
| return <AlertTriangle className={`${iconClass} text-red-400`} />; | |
| } | |
| return <MessageSquareText className={`${iconClass} text-gray-400`} />; | |
| default: | |
| // For dynamic role names, use a generic bot icon with dynamic color | |
| const colorClasses = [ | |
| 'text-green-400', 'text-purple-400', 'text-blue-400', | |
| 'text-yellow-400', 'text-pink-400', 'text-indigo-400' | |
| ]; | |
| const colorIndex = typeof sender === 'string' ? | |
| sender.length % colorClasses.length : 0; | |
| return <Lightbulb className={`${iconClass} ${colorClasses[colorIndex]}`} />; | |
| } | |
| }; | |
| const getSenderNameStyle = (sender: MessageSender): string => { | |
| switch (sender) { | |
| case MessageSender.User: return "text-blue-300"; | |
| case MessageSender.Cognito: return "text-green-300"; | |
| case MessageSender.Muse: return "text-purple-300"; | |
| case MessageSender.System: return "text-gray-400"; | |
| default: | |
| // For dynamic role names, apply dynamic colors | |
| const colorClasses = [ | |
| 'text-green-300', 'text-purple-300', 'text-blue-300', | |
| 'text-yellow-300', 'text-pink-300', 'text-indigo-300' | |
| ]; | |
| const colorIndex = typeof sender === 'string' ? | |
| sender.length % colorClasses.length : 0; | |
| return colorClasses[colorIndex]; | |
| } | |
| } | |
| const getBubbleStyle = (sender: MessageSender, purpose: MessagePurpose, messageText: string): string => { | |
| let baseStyle = "mb-4 p-4 rounded-lg shadow-md max-w-xl break-words relative "; | |
| if (purpose === MessagePurpose.SystemNotification) { | |
| if ( | |
| messageText.toLowerCase().includes("error") || | |
| messageText.toLowerCase().includes("错误") || | |
| messageText.toLowerCase().includes("警告") || | |
| messageText.toLowerCase().includes("critical") || | |
| messageText.toLowerCase().includes("严重") | |
| ) { | |
| return baseStyle + "bg-red-800 border border-red-700 text-center text-sm italic mx-auto text-red-200"; | |
| } | |
| return baseStyle + "bg-gray-700 text-center text-sm italic mx-auto"; | |
| } | |
| switch (sender) { | |
| case MessageSender.User: | |
| return baseStyle + "bg-blue-600 ml-auto rounded-br-none"; | |
| case MessageSender.Cognito: | |
| return baseStyle + "bg-green-700 mr-auto rounded-bl-none"; | |
| case MessageSender.Muse: | |
| return baseStyle + "bg-purple-700 mr-auto rounded-bl-none"; | |
| default: | |
| // For dynamic role names, use varied background colors | |
| const bgColors = [ | |
| 'bg-green-700', 'bg-purple-700', 'bg-blue-700', | |
| 'bg-yellow-700', 'bg-pink-700', 'bg-indigo-700' | |
| ]; | |
| const bgIndex = typeof sender === 'string' ? | |
| sender.length % bgColors.length : 0; | |
| return baseStyle + bgColors[bgIndex] + " mr-auto rounded-bl-none"; | |
| } | |
| }; | |
| const getPurposePrefix = (purpose: MessagePurpose, sender: MessageSender): string => { | |
| switch (purpose) { | |
| case MessagePurpose.CognitoToMuse: | |
| return `致 ${MessageSender.Muse}的消息: `; | |
| case MessagePurpose.MuseToCognito: | |
| return `致 ${MessageSender.Cognito}的消息: `; | |
| case MessagePurpose.FinalResponse: | |
| return `最终答案: `; | |
| default: | |
| return ""; | |
| } | |
| } | |
| interface MessageBubbleProps { | |
| message: ChatMessage; | |
| } | |
| const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => { | |
| const { text: messageText, sender, purpose, timestamp, durationMs, image } = message; | |
| const formattedTime = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| const [isCopied, setIsCopied] = useState(false); | |
| // 移除所有流式动画逻辑,直接显示内容 | |
| const isDiscussionStep = purpose === MessagePurpose.CognitoToMuse || purpose === MessagePurpose.MuseToCognito; | |
| const isFinalResponse = purpose === MessagePurpose.FinalResponse; | |
| const showDuration = durationMs !== undefined && durationMs > 0; | |
| const shouldRenderMarkdown = | |
| (sender === MessageSender.User || sender === MessageSender.Cognito || sender === MessageSender.Muse || typeof sender === 'string') && | |
| purpose !== MessagePurpose.SystemNotification; | |
| let sanitizedHtml = ''; | |
| if (shouldRenderMarkdown && messageText) { | |
| const rawHtml = marked.parse(messageText) as string; | |
| sanitizedHtml = DOMPurify.sanitize(rawHtml); | |
| } | |
| const handleCopy = async () => { | |
| try { | |
| await navigator.clipboard.writeText(messageText); | |
| setIsCopied(true); | |
| setTimeout(() => setIsCopied(false), 2000); | |
| } catch (err) { | |
| console.error('无法复制文本: ', err); | |
| } | |
| }; | |
| const canCopy = (sender === MessageSender.User || sender === MessageSender.Cognito || sender === MessageSender.Muse || typeof sender === 'string') && purpose !== MessagePurpose.SystemNotification; | |
| return ( | |
| <div className={`flex ${sender === MessageSender.User ? 'justify-end' : 'justify-start'}`}> | |
| <div className={getBubbleStyle(sender, purpose, messageText)}> | |
| {canCopy && ( | |
| <button | |
| onClick={handleCopy} | |
| title={isCopied ? "已复制!" : "复制消息"} | |
| className="absolute top-1.5 right-1.5 p-1 text-gray-400 hover:text-sky-300 transition-colors rounded-md focus:outline-none focus:ring-1 focus:ring-sky-500" | |
| > | |
| {isCopied ? <Check size={16} className="text-green-400" /> : <Copy size={16} />} | |
| </button> | |
| )} | |
| <div className="flex items-center mb-1"> | |
| <SenderIcon sender={sender} purpose={purpose} messageText={messageText} /> | |
| <span className={`font-semibold ${getSenderNameStyle(sender)}`}>{sender}</span> | |
| {isDiscussionStep && <span className="ml-2 text-xs text-gray-400">(内部讨论)</span>} | |
| </div> | |
| {messageText ? ( | |
| shouldRenderMarkdown ? ( | |
| <div | |
| className="chat-markdown-content text-sm text-gray-200" | |
| dangerouslySetInnerHTML={{ __html: sanitizedHtml }} | |
| /> | |
| ) : ( | |
| <p className="text-sm text-gray-200 whitespace-pre-wrap"> | |
| {messageText} | |
| </p> | |
| ) | |
| ) : ( | |
| <p className="text-sm text-gray-400 italic"> | |
| 正在生成回复... | |
| </p> | |
| )} | |
| {image && sender === MessageSender.User && ( | |
| <div className={`mt-2 ${messageText ? 'pt-2 border-t border-blue-500' : ''}`}> | |
| <img | |
| src={image.dataUrl} | |
| alt={image.name || "用户上传的图片"} | |
| className="max-w-xs max-h-64 rounded-md object-contain" | |
| /> | |
| </div> | |
| )} | |
| <div className="text-xs text-gray-400 mt-2 flex justify-between items-center"> | |
| <span>{formattedTime}</span> | |
| {showDuration && ( | |
| <span className="italic"> (耗时: {(durationMs / 1000).toFixed(2)}s)</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default MessageBubble; |