2025-09-23 13:23:30 +08:00

156 lines
5.5 KiB
TypeScript
Raw Permalink 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 React, { useMemo } from "react";
import { motion } from "framer-motion";
import { ChatMessage, MessageBlock } from "./types";
import { bubbleVariants, hhmm } from "./utils";
import { ProgressBar } from "./ProgressBar";
import { Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { Image } from 'antd';
import { useDeviceType } from '@/hooks/useDeviceType';
interface MessageRendererProps {
msg: ChatMessage;
sendMessage: (blocks: MessageBlock[]) => Promise<void>;
}
export function MessageRenderer({ msg, sendMessage }: MessageRendererProps) {
// Decide bubble style
const isUser = msg.role === "user";
const isSystem = msg.role === "system";
const { isMobile, isTablet, isDesktop } = useDeviceType();
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"
key={msg.id}
>
<div className={`${isMobile ? 'max-w-full' : '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"
preview={{
getContainer: () => document.querySelector('[data-alt="smart-chat-box"]') || document.body,
mask: <div className="absolute inset-0 backdrop-blur-sm bg-black/60" />,
maskClassName: "!bg-black/60",
rootClassName: "!z-[1000]"
}}
/>
</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="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>;
case "applyButton":
return <button key={idx} className="bg-[#6fd0d3] text-white px-2 py-1 rounded-md" onClick={() => {
// 帮用户发送一条消息消息内容是confirm apply
sendMessage([{ type: "text", text: "confirm apply" }]);
}}>{b.text}</button>;
default:
return null;
}
})}
</div>
</div>
</motion.div>
);
}