432 lines
15 KiB
TypeScript
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;
|
|
}
|