video-flow-b/components/SmartChatBox/MessageRenderer.tsx
2025-08-29 06:28:08 +08:00

137 lines
4.5 KiB
TypeScript

import React, { useMemo } from "react";
import { motion } from "framer-motion";
import { ChatMessage } from "./types";
import { bubbleVariants, hhmm } from "./utils";
import { ProgressBar } from "./ProgressBar";
import { Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { Image } from 'antd';
interface MessageRendererProps {
msg: ChatMessage;
}
export function MessageRenderer({ msg }: MessageRendererProps) {
// Decide bubble style
const isUser = msg.role === "user";
const isSystem = msg.role === "system";
const bubbleClass = useMemo(() => {
if (isSystem) return "bg-[#281c1459] text-white";
if (isUser) return "bg-[#27416c59] text-white";
return "bg-[#281c1459] text-white"; // assistant
}, [isSystem, isUser]);
const badge = isSystem ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-amber-200 text-amber-900 mr-2">
</span>
) : msg.role === "assistant" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-full bg-[#6240274d] text-gray-100 mr-2">
</span>
) : null;
// 状态图标和颜色
const statusIcon = useMemo(() => {
if (!isUser) return null;
switch (msg.status) {
case 'pending':
return <Loader2 size={12} className="animate-spin text-gray-300" />;
case 'success':
return <CheckCircle2 size={12} className="text-green-400" />;
case 'error':
return <AlertCircle size={12} className="text-red-400" />;
default:
return null;
}
}, [isUser, msg.status]);
// 消息类型标签
const typeLabel = useMemo(() => {
if (!isUser) return null;
const isTask = msg.chatType !== 'chat';
if (isTask) {
return (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-blue-500/30 text-blue-200">
</span>
);
}
return (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-gray-500/30 text-gray-200">
</span>
);
}, [isUser, msg.chatType]);
return (
<motion.div
className={`w-full flex ${isUser ? "justify-end" : "justify-start"}`}
custom={isUser}
variants={bubbleVariants}
initial="hidden"
animate="visible"
transition={{ duration: 0.25 }}
data-alt="message-bubble"
>
<div className={`max-w-[75%] rounded-2xl shadow-md p-3 ${bubbleClass}`}>
{/* Header */}
{/* <div className="flex items-center gap-2 text-[11px] opacity-80 mb-1">
{badge}
<div className="flex items-center gap-1.5">
{typeLabel}
<span>{hhmm(msg.createdAt)}</span>
{statusIcon}
</div>
</div> */}
{/* Content blocks */}
<div className="space-y-2">
{msg.blocks.map((b, idx) => {
switch (b.type) {
case "text":
return (
<p key={idx} className="leading-relaxed whitespace-pre-wrap break-words">
{b.text}
</p>
);
case "image":
return (
<div key={idx} className="overflow-hidden rounded-xl border border-white/10">
<Image src={b.url} alt={b.alt || "image"} className="max-h-72 object-contain w-full bg-black/10" />
</div>
);
case "video":
return (
<div key={idx} className="overflow-hidden rounded-xl">
<video
controls
src={b.url}
poster={b.poster}
className="w-full max-h-80 bg-black"
controlsList="nodownload noremoteplayback"
disablePictureInPicture
disableRemotePlayback
onContextMenu={e => e.preventDefault()}
/>
</div>
);
case "audio":
return (
<audio key={idx} className="w-full" controls src={b.url} />
);
case "progress":
return <ProgressBar key={idx} value={b.value} total={b.total} label={b.label} />;
case "link":
return <a key={idx} href={b.url} className="underline hover:underline text-[rgb(111 208 211)]">{b.text}</a>;
default:
return null;
}
})}
</div>
</div>
</motion.div>
);
}