import { useState } from 'react' import { Check, CheckCheck, Clock, AlertCircle, Copy, Reply, Forward, StickyNote, Star, ListTodo, BookOpen, MoreHorizontal, Languages, Globe, ChevronDown, ChevronUp, Download, FileText, LoaderCircle, SmilePlus, Trash2, Brain, } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import type { ChannelType, Message } from '@/types' import { cn } from '@/utils/cn' import { toast } from '@/app/store/toast' import { formatTime } from '@/utils/format' import { languageName } from '@/config/languages' import { Dropdown } from '@/components/ui/dropdown' import { Avatar } from '@/components/ui/avatar' import { getMessageAttachment } from '@/services/api/conversations' export type ViewMode = 'original' | 'translated' | 'bilingual' function EmailAttachmentLink({ conversationId, messageId, attachment, }: { conversationId: string messageId: string attachment: NonNullable[number] }) { const { data, isLoading, error } = useQuery({ queryKey: ['email-attachment', conversationId, messageId, attachment.id], queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id), enabled: attachment.available, staleTime: Infinity, retry: 1, }) const size = attachment.size >= 1024 * 1024 ? `${(attachment.size / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(attachment.size / 1024))} KB` return ( { if (!data?.dataUrl) event.preventDefault() }} className={cn( 'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors', data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50', )} > {isLoading ? : } {attachment.name} {error ? '附件暂不可用' : size} {data?.dataUrl && } ) } export function MessageBubble({ msg, viewMode, onQuote, onMarkImportant, onConvertToTask, onAddToKnowledge, onAddNote, onReact, onDelete, customerName, customerAvatar, channel, showIntent = true, }: { msg: Message viewMode: ViewMode onQuote: () => void onMarkImportant: () => void onConvertToTask: () => void onAddToKnowledge: () => void onAddNote: () => void onReact?: (emoji: string) => void onDelete?: () => void customerName?: string customerAvatar?: string channel?: ChannelType showIntent?: boolean }) { const [showActions, setShowActions] = useState(false) const [translationExpanded, setTranslationExpanded] = useState(false) const isMe = msg.sender === 'agent' const isSystem = msg.sender === 'system' const originalText = String(msg.originalText || '') const rawTranslation = String(msg.translatedText || '') const translationCorrupted = /(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) || rawTranslation.length > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8)) const translatedText = translationCorrupted ? '' : rawTranslation const hasTranslation = translatedText && translatedText !== originalText const outgoingChineseNote = isMe ? String( msg.translationCn || (hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''), ).trim() : '' const outgoingSentText = isMe && translatedText ? translatedText : originalText const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6 const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang)) const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文' const targetLanguage = msg.targetLang && !['und', 'auto', 'unknown'].includes(msg.targetLang) ? languageName(msg.targetLang) : '客户语言' const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram' const contextualTranslation = msg.translationMethod === 'ai-contextual' && Number(msg.translationContextMessages || 0) > 0 const translationTrace = contextualTranslation ? `AI 结合最近 ${msg.translationContextMessages} 条会话消息${msg.translationConversationSummaryApplied ? '及客户画像摘要' : ''}完成翻译` : 'AI 直接翻译当前消息' const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type) const hasProviderAttachment = Boolean( msg.telegramFileId || (msg.whatsappMessageId && isMediaMessage), ) const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({ queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId], queryFn: () => getMessageAttachment(msg.conversationId, msg.id), enabled: hasProviderAttachment, staleTime: Infinity, retry: 1, }) if (isSystem) { return (
{originalText}
) } const translationContent = (muted = true) => (

{translatedText}

{longTranslation && ( )}
) const renderContent = () => { if (msg.isDeleted) { return (

该消息已撤回

) } if (msg.type !== 'text') { const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' } return originalText ? (

{originalText}

) : (
{labels[msg.type]}
) } if (!originalText.trim()) { return

{providerName} 服务通知

} if ( (viewMode === 'bilingual' || viewMode === 'translated') && msg.sender === 'customer' && msg.type === 'text' && (!translatedText || translationCorrupted) ) { return (

{originalText}

{translationCorrupted ? '检测到异常译文,系统正在自动修复' : msg.translationStatus === 'failed' ? 'AI 翻译暂时失败,将自动重试' : 'AI 正在翻译…'}
) } if (isMe && viewMode === 'bilingual') { return (
客户收到 · {translatedText ? targetLanguage : '原文'}

{outgoingSentText}

{outgoingChineseNote ? (
中文备注 · 仅内部可见

{outgoingChineseNote}

) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
) : null}
) } if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) { return (
中文备注 · 仅内部可见

{outgoingChineseNote}

) } if (viewMode === 'bilingual' && hasTranslation) { return (
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}

{originalText}

{isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
{translationContent()}
) } if (viewMode === 'translated' && translatedText) { return (
{isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
{translationContent(false)}
) } return (
{hasKnownLanguage && ( {sourceLanguage} )}

{originalText}

) } const statusIcon = () => { switch (msg.status) { case 'sending': return case 'sent': return case 'delivered': return case 'read': return case 'failed': return default: return null } } const moreActions = [ { key: 'copy', label: '复制', icon: Copy, onSelect: () => { void navigator.clipboard.writeText(originalText); toast.success('已复制') } }, { key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote }, ...(channel === 'whatsapp' && onReact ? [{ key: 'react', label: '👍 回应', icon: SmilePlus, onSelect: () => onReact('👍') }] : []), { key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote }, { key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant }, { key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask }, { key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge }, ...(channel === 'whatsapp' && isMe && onDelete ? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }] : []), ] return (
setShowActions(true)} onMouseLeave={() => setShowActions(false)} > {!isMe && ( )}
{!isMe && (

{msg.senderName || customerName}

)} {/* 消息气泡 */}
{msg.replyToId && (

回复消息

{msg.replyPreview || `消息 #${msg.replyToId}`}

)} {renderContent()} {!isMe && showIntent && msg.intentAnalysis && (
意图 · {msg.intentAnalysis.label} 可信度 {Math.round(Number(msg.intentAnalysis.confidence || 0) * 100)}% {['high', 'urgent'].includes(msg.intentAnalysis.urgency) && ( {msg.intentAnalysis.urgency === 'urgent' ? '紧急' : '高优先级'} )} {msg.intentAnalysis.entities?.slice(0, 3).map((entity) => ( {entity} ))}
)} {hasProviderAttachment && attachmentLoading && (
正在读取 {providerName} 文件…
)} {attachment && msg.type === 'image' && ( {attachment.name} )} {attachment && msg.type === 'audio' && (
{/* 底栏:时间 + 状态 */}
{formatTime(msg.createdAt)} {msg.editedAt && !msg.isDeleted && 已编辑} {isMe && statusIcon()} {msg.isImportant && } {msg.internalNote && }
{/* 悬停操作按钮 */}
{showActions && ( } items={moreActions} /> )}
) }