iconsulting/packages/web-client/src/features/chat/presentation/components/MessageBubble.tsx

432 lines
15 KiB
TypeScript

import { clsx } from 'clsx';
import { User, Bot, Image, FileText, Download, ExternalLink, CheckCircle, Clock, XCircle, AlertCircle, ShoppingBag } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { QRCodeSVG } from 'qrcode.react';
import { FileAttachment } from '../stores/chatStore';
import { AssessmentResultCard } from './AssessmentResultCard';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: string;
attachments?: FileAttachment[];
metadata?: {
toolCalls?: Array<{
name: string;
result: unknown;
}>;
};
}
interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
}
export function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
const isUser = message.role === 'user';
const hasAttachments = message.attachments && message.attachments.length > 0;
return (
<div
className={clsx(
'flex gap-3 message-enter',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{/* Avatar */}
<div
className={clsx(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser ? 'bg-primary-100' : 'bg-secondary-100',
)}
>
{isUser ? (
<User className="w-4 h-4 text-primary-600" />
) : (
<Bot className="w-4 h-4 text-secondary-600" />
)}
</div>
{/* Message content */}
<div className="max-w-[80%] space-y-2">
{/* 文件附件 */}
{hasAttachments && (
<div className={clsx('flex flex-wrap gap-2', isUser ? 'justify-end' : 'justify-start')}>
{message.attachments!.map((attachment) => (
<AttachmentPreview key={attachment.id} attachment={attachment} isUser={isUser} />
))}
</div>
)}
{/* 文本内容 */}
{message.content && (
<div
className={clsx(
'rounded-2xl px-4 py-3',
isUser
? 'bg-primary-600 text-white rounded-tr-sm'
: 'bg-secondary-100 text-secondary-900 rounded-tl-sm',
)}
>
<div className="prose prose-sm max-w-none break-words">
{isUser ? (
<p className="m-0 whitespace-pre-wrap">{message.content}</p>
) : (
<ReactMarkdown
components={{
h1: ({ children }) => <h2 className="text-lg font-bold mt-3 mb-2 first:mt-0">{children}</h2>,
h2: ({ children }) => <h3 className="text-base font-bold mt-3 mb-2 first:mt-0">{children}</h3>,
h3: ({ children }) => <h4 className="text-sm font-bold mt-2 mb-1">{children}</h4>,
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="my-2 pl-4 list-disc">{children}</ul>,
ol: ({ children }) => <ol className="my-2 pl-4 list-decimal">{children}</ol>,
li: ({ children }) => <li className="my-1">{children}</li>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
a: ({ href, children }) => (
<a href={href} className="text-primary-600 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{message.content}
</ReactMarkdown>
)}
{isStreaming && (
<span className="inline-block w-1 h-4 ml-1 bg-current animate-pulse" />
)}
</div>
{/* Tool call results (e.g., payment QR code) */}
{message.metadata?.toolCalls?.map((toolCall, index) => (
<ToolCallResult key={index} toolCall={toolCall} />
))}
</div>
)}
</div>
</div>
);
}
function AttachmentPreview({
attachment,
isUser,
}: {
attachment: FileAttachment;
isUser: boolean;
}) {
const isImage = attachment.type === 'image';
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
if (isImage && (attachment.downloadUrl || attachment.thumbnailUrl)) {
return (
<a
href={attachment.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="block group relative"
>
<img
src={attachment.thumbnailUrl || attachment.downloadUrl}
alt={attachment.originalName}
className={clsx(
'max-w-[200px] max-h-[200px] rounded-lg object-cover',
'border-2 transition-all',
isUser ? 'border-primary-400' : 'border-secondary-200',
'group-hover:opacity-90',
)}
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 rounded-lg">
<ExternalLink className="w-6 h-6 text-white" />
</div>
</a>
);
}
return (
<a
href={attachment.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className={clsx(
'flex items-center gap-3 px-3 py-2 rounded-lg transition-all',
isUser
? 'bg-primary-500 hover:bg-primary-400'
: 'bg-white border border-secondary-200 hover:border-secondary-300',
)}
>
{isImage ? (
<Image className={clsx('w-8 h-8', isUser ? 'text-white/80' : 'text-blue-500')} />
) : (
<FileText className={clsx('w-8 h-8', isUser ? 'text-white/80' : 'text-orange-500')} />
)}
<div className="flex flex-col">
<span
className={clsx(
'text-sm max-w-[150px] truncate',
isUser ? 'text-white' : 'text-secondary-700',
)}
>
{attachment.originalName}
</span>
<span className={clsx('text-xs', isUser ? 'text-white/70' : 'text-secondary-400')}>
{formatFileSize(attachment.size)}
</span>
</div>
<Download className={clsx('w-4 h-4 ml-1', isUser ? 'text-white/70' : 'text-secondary-400')} />
</a>
);
}
function ToolCallResult({
toolCall,
}: {
toolCall: { name: string; result: unknown };
}) {
if (toolCall.name === 'generate_payment') {
const result = toolCall.result as {
success?: boolean;
qrCodeUrl?: string;
paymentUrl?: string;
amount?: number;
currency?: string;
message?: string;
orderId?: string;
expiresAt?: string;
error?: string;
};
if (!result.success) {
return (
<div className="mt-3 p-3 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<p className="text-sm text-red-600">{result.error || '支付创建失败'}</p>
</div>
</div>
);
}
return (
<div className="mt-3 p-4 bg-white rounded-lg border border-secondary-200">
<p className="text-sm text-secondary-600 mb-3">{result.message}</p>
<div className="flex flex-col items-center">
{result.qrCodeUrl ? (
<div className="p-3 bg-white rounded-lg border border-secondary-100">
<QRCodeSVG value={result.qrCodeUrl} size={160} level="M" />
</div>
) : result.paymentUrl ? (
<a
href={result.paymentUrl}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
</a>
) : null}
<p className="mt-3 text-xl font-semibold text-primary-600">
¥{result.amount}
</p>
{result.orderId && (
<p className="mt-1 text-xs text-secondary-400">
: {result.orderId}
</p>
)}
{result.expiresAt && (
<p className="mt-1 text-xs text-secondary-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
: {new Date(result.expiresAt).toLocaleTimeString('zh-CN')}
</p>
)}
</div>
</div>
);
}
if (toolCall.name === 'check_payment_status') {
const result = toolCall.result as {
success?: boolean;
orderId?: string;
status?: string;
statusLabel?: string;
paidAt?: string;
error?: string;
};
if (!result.success) {
return (
<div className="mt-3 p-3 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<p className="text-sm text-red-600">{result.error || '查询失败'}</p>
</div>
</div>
);
}
const statusConfig: Record<string, { icon: React.ReactNode; color: string }> = {
PAID: { icon: <CheckCircle className="w-4 h-4 text-green-500" />, color: 'bg-green-50 border-green-200' },
COMPLETED: { icon: <CheckCircle className="w-4 h-4 text-green-500" />, color: 'bg-green-50 border-green-200' },
PENDING_PAYMENT: { icon: <Clock className="w-4 h-4 text-yellow-500" />, color: 'bg-yellow-50 border-yellow-200' },
CREATED: { icon: <Clock className="w-4 h-4 text-yellow-500" />, color: 'bg-yellow-50 border-yellow-200' },
CANCELLED: { icon: <XCircle className="w-4 h-4 text-red-500" />, color: 'bg-red-50 border-red-200' },
REFUNDED: { icon: <XCircle className="w-4 h-4 text-orange-500" />, color: 'bg-orange-50 border-orange-200' },
};
const config = statusConfig[result.status || ''] || { icon: <Clock className="w-4 h-4 text-secondary-400" />, color: 'bg-secondary-50 border-secondary-200' };
return (
<div className={clsx('mt-3 p-3 rounded-lg border', config.color)}>
<div className="flex items-center gap-2">
{config.icon}
<span className="text-sm font-medium">{result.statusLabel || result.status}</span>
</div>
{result.orderId && (
<p className="mt-1 text-xs text-secondary-500">: {result.orderId}</p>
)}
{result.paidAt && (
<p className="mt-1 text-xs text-secondary-500">
: {new Date(result.paidAt).toLocaleString('zh-CN')}
</p>
)}
</div>
);
}
if (toolCall.name === 'query_order_history') {
const result = toolCall.result as {
success?: boolean;
totalOrders?: number;
orders?: Array<{
orderId: string;
serviceType: string;
serviceCategory?: string;
amount: number;
currency: string;
status: string;
paidAt?: string;
createdAt: string;
}>;
error?: string;
};
if (!result.success) {
return (
<div className="mt-3 p-3 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<p className="text-sm text-red-600">{result.error || '查询失败'}</p>
</div>
</div>
);
}
if (!result.orders || result.orders.length === 0) {
return (
<div className="mt-3 p-3 bg-secondary-50 rounded-lg border border-secondary-200">
<div className="flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-secondary-400" />
<p className="text-sm text-secondary-500"></p>
</div>
</div>
);
}
const statusLabels: Record<string, string> = {
CREATED: '待支付',
PENDING_PAYMENT: '待支付',
PAID: '已支付',
PROCESSING: '处理中',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDED: '已退款',
};
return (
<div className="mt-3 p-3 bg-white rounded-lg border border-secondary-200">
<div className="flex items-center gap-2 mb-2">
<ShoppingBag className="w-4 h-4 text-primary-500" />
<span className="text-sm font-medium"> ({result.totalOrders})</span>
</div>
<div className="space-y-2">
{result.orders.map((order) => (
<div key={order.orderId} className="flex items-center justify-between p-2 bg-secondary-50 rounded text-xs">
<div>
<span className="font-medium">{order.serviceCategory || order.serviceType}</span>
<span className="ml-2 text-secondary-400">
{new Date(order.createdAt).toLocaleDateString('zh-CN')}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">¥{order.amount}</span>
<span className={clsx(
'px-1.5 py-0.5 rounded text-xs',
order.status === 'PAID' || order.status === 'COMPLETED' ? 'bg-green-100 text-green-700' :
order.status === 'CANCELLED' || order.status === 'REFUNDED' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
)}>
{statusLabels[order.status] || order.status}
</span>
</div>
</div>
))}
</div>
</div>
);
}
if (toolCall.name === 'invoke_assessment_expert') {
// Assessment expert returns a JSON string; parse it
try {
const raw = toolCall.result;
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (data?.assessments && Array.isArray(data.assessments)) {
return <AssessmentResultCard data={data} />;
}
} catch {
// Not parseable — fall through to null
}
}
if (toolCall.name === 'cancel_order') {
const result = toolCall.result as {
success?: boolean;
orderId?: string;
message?: string;
error?: string;
};
return (
<div className={clsx(
'mt-3 p-3 rounded-lg border',
result.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200',
)}>
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<AlertCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm">
{result.success ? result.message : result.error}
</span>
</div>
{result.orderId && (
<p className="mt-1 text-xs text-secondary-500">: {result.orderId}</p>
)}
</div>
);
}
return null;
}