Files
lingxi-web/src/components/inbox/MessageBubble.tsx
T
wmq af49d22ca7 feat: release contextual omnichannel customer platform
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
2026-07-30 02:29:53 +08:00

504 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Message['emailAttachments']>[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 (
<a
href={data?.dataUrl}
download={data?.name || attachment.name}
onClick={(event) => {
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 ? <LoaderCircle className="h-5 w-5 animate-spin text-sky-500" /> : <FileText className="h-5 w-5 text-sky-500" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-slate-700">{attachment.name}</span>
<span className="text-[10px] text-slate-400">{error ? '附件暂不可用' : size}</span>
</span>
{data?.dataUrl && <Download className="h-4 w-4 text-slate-400" />}
</a>
)
}
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 (
<div className="flex justify-center py-2">
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{originalText}</span>
</div>
)
}
const translationContent = (muted = true) => (
<div>
<p
className={cn(
'whitespace-pre-wrap break-words text-sm leading-6',
muted && 'text-foreground/75',
longTranslation && !translationExpanded && 'max-h-28 overflow-hidden',
)}
>
{translatedText}
</p>
{longTranslation && (
<button
type="button"
onClick={() => setTranslationExpanded((value) => !value)}
className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-primary hover:bg-primary-50"
>
{translationExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{translationExpanded ? '收起译文' : '展开完整译文'}
</button>
)}
</div>
)
const renderContent = () => {
if (msg.isDeleted) {
return (
<p className="flex items-center gap-1.5 text-xs italic text-slate-400">
<AlertCircle className="h-3.5 w-3.5" />
该消息已撤回
</p>
)
}
if (msg.type !== 'text') {
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
return originalText ? (
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{originalText}</p>
) : (
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400" />
{labels[msg.type]}
</div>
)
}
if (!originalText.trim()) {
return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
}
if (
(viewMode === 'bilingual' || viewMode === 'translated') &&
msg.sender === 'customer' &&
msg.type === 'text' &&
(!translatedText || translationCorrupted)
) {
return (
<div className="space-y-2.5">
<p className="whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
<div className="flex items-center gap-1.5 border-t border-border/60 pt-2 text-xs text-muted-foreground">
<Languages className="h-3.5 w-3.5 text-primary" />
{translationCorrupted
? '检测到异常译文,系统正在自动修复'
: msg.translationStatus === 'failed'
? 'AI 翻译暂时失败,将自动重试'
: 'AI 正在翻译…'}
</div>
</div>
)
}
if (isMe && viewMode === 'bilingual') {
return (
<div className="space-y-2.5">
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100/80 px-2 py-0.5 text-[10px] font-medium text-blue-700">
<Globe className="h-3 w-3" />
客户收到 · {translatedText ? targetLanguage : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
</div>
{outgoingChineseNote ? (
<div data-testid="outgoing-chinese-note" className="border-t border-blue-200/60 pt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<Languages className="h-3 w-3 text-primary" />
中文备注 · 仅内部可见
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
{outgoingChineseNote}
</p>
</div>
) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
<div className="flex items-center gap-1.5 border-t border-blue-200/60 pt-2 text-xs text-slate-500">
<Languages className="h-3.5 w-3.5 text-primary" />
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
</div>
) : null}
</div>
)
}
if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<Languages className="h-3 w-3 text-primary" />
中文备注 · 仅内部可见
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
{outgoingChineseNote}
</p>
</div>
)
}
if (viewMode === 'bilingual' && hasTranslation) {
return (
<div className="space-y-2.5">
<div>
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
</div>
<div className="border-t border-border/60 pt-2">
<span
title={translationTrace}
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary"
>
<Languages className="h-3 w-3" />
{isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
</span>
<div className="mt-1.5">{translationContent()}</div>
</div>
</div>
)
}
if (viewMode === 'translated' && translatedText) {
return (
<div>
<span
title={translationTrace}
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary"
>
<Languages className="h-3 w-3" />
{isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
</span>
<div className="mt-1.5">{translationContent(false)}</div>
</div>
)
}
return (
<div>
{hasKnownLanguage && (
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{sourceLanguage}
</span>
)}
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
{originalText}
</p>
</div>
)
}
const statusIcon = () => {
switch (msg.status) {
case 'sending':
return <Clock className="h-3 w-3 text-muted-foreground animate-pulse" />
case 'sent':
return <Check className="h-3 w-3 text-muted-foreground" />
case 'delivered':
return <CheckCheck className="h-3 w-3 text-muted-foreground" />
case 'read':
return <CheckCheck className="h-3 w-3 text-primary" />
case 'failed':
return <AlertCircle className="h-3 w-3 text-red-500" />
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 (
<div
data-testid="message-bubble"
data-sender={msg.sender}
className={cn(
'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5',
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
)}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{!isMe && (
<Avatar
name={customerName || msg.senderName || `${providerName} 用户`}
src={customerAvatar}
size="xs"
className="order-1 mt-1"
/>
)}
<div className={cn('min-w-0 max-w-[min(84%,760px)] sm:max-w-[min(74%,760px)]', isMe ? 'order-2' : 'order-1')}>
{!isMe && (
<p className="mb-1 px-1 text-[11px] font-medium text-muted-foreground">
{msg.senderName || customerName}
</p>
)}
{/* 消息气泡 */}
<div
data-testid="message-surface"
data-direction={isMe ? 'outgoing' : 'incoming'}
className={cn(
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
isMe
? 'telegram-bubble-out rounded-br-[5px]'
: 'telegram-bubble-in rounded-bl-[5px]',
msg.isImportant && 'ring-1 ring-amber-400',
)}
>
{msg.replyToId && (
<div className="mb-2 rounded-lg border-s-2 border-primary/70 bg-muted/60 px-2.5 py-1.5">
<p className="text-[10px] font-medium text-primary">回复消息</p>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{msg.replyPreview || `消息 #${msg.replyToId}`}
</p>
</div>
)}
{renderContent()}
{!isMe && showIntent && msg.intentAnalysis && (
<div
data-testid="message-intent"
title={msg.intentAnalysis.rationale}
className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-slate-200/70 pt-2"
>
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-700">
<Brain className="h-3 w-3" />
意图 · {msg.intentAnalysis.label}
</span>
<span className="text-[10px] text-slate-400">
可信度 {Math.round(Number(msg.intentAnalysis.confidence || 0) * 100)}%
</span>
{['high', 'urgent'].includes(msg.intentAnalysis.urgency) && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
{msg.intentAnalysis.urgency === 'urgent' ? '紧急' : '高优先级'}
</span>
)}
{msg.intentAnalysis.entities?.slice(0, 3).map((entity) => (
<span key={entity} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{entity}
</span>
))}
</div>
)}
{hasProviderAttachment && attachmentLoading && (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
</div>
)}
{attachment && msg.type === 'image' && (
<a href={attachment.dataUrl} download={attachment.name} className="mt-2 block overflow-hidden rounded-xl">
<img src={attachment.dataUrl} alt={attachment.name} className="max-h-80 w-auto max-w-full object-contain" />
</a>
)}
{attachment && msg.type === 'audio' && (
<audio controls preload="metadata" src={attachment.dataUrl} className="mt-2 h-10 max-w-full" />
)}
{attachment && msg.type === 'file' && attachment.mimeType === 'image/webp' && (
<img src={attachment.dataUrl} alt={msg.stickerEmoji || attachment.name} className="mt-2 h-32 w-32 object-contain" />
)}
{attachment && msg.type === 'file' && attachment.mimeType === 'video/webm' && (
<video src={attachment.dataUrl} autoPlay loop muted playsInline className="mt-2 h-32 w-32 object-contain" />
)}
{attachment && msg.type === 'file' && !['image/webp', 'video/webm'].includes(attachment.mimeType) && (
<a
href={attachment.dataUrl}
download={attachment.name}
className="mt-2 flex items-center gap-2 rounded-xl border border-border bg-muted/50 px-3 py-2 hover:bg-muted"
>
<FileText className="h-5 w-5 text-primary" />
<span className="min-w-0 flex-1 truncate text-xs">{attachment.name}</span>
<Download className="h-4 w-4 text-muted-foreground" />
</a>
)}
{hasProviderAttachment && attachmentError && (
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
)}
{msg.emailAttachments?.map((emailAttachment) => (
<EmailAttachmentLink
key={emailAttachment.id}
conversationId={msg.conversationId}
messageId={msg.id}
attachment={emailAttachment}
/>
))}
{Boolean(msg.reactions?.length) && (
<div className="mt-2 flex flex-wrap gap-1">
{msg.reactions?.map((reaction, index) => (
<span
key={`${reaction.actor}-${reaction.emoji}-${index}`}
title={reaction.fromMe ? '我' : reaction.actor}
className="inline-flex min-w-7 items-center justify-center rounded-full border border-blue-200 bg-blue-50/90 px-2 py-0.5 text-sm shadow-sm"
>
{reaction.emoji}
</span>
))}
</div>
)}
</div>
{/* 底栏:时间 + 状态 */}
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
<span>{formatTime(msg.createdAt)}</span>
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
{isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
</div>
</div>
{/* 悬停操作按钮 */}
<div className={cn('flex items-center', isMe ? 'order-1' : 'order-2')}>
{showActions && (
<Dropdown
trigger={
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted">
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</button>
}
items={moreActions}
/>
)}
</div>
</div>
)
}