Files
EPEEAIKit/art-agent/frontend/src/components/chat/chat-messages.tsx

187 lines
7.0 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.
"use client";
import { useState, useCallback, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { ChatMessage, ImageAsset } from "@/lib/types";
import { ImageGrid } from "./image-grid";
import { messageEnter, assistantEnter } from "@/components/ui/motion-presets";
function StreamingLines({ text }: { text: string }) {
const lines = useMemo(() => text.split("\n"), [text]);
const total = lines.length;
return (
<div className="whitespace-pre-wrap text-sm leading-relaxed">
{lines.map((line, i) => {
const fromEnd = total - 1 - i;
const opacity = fromEnd <= 0 ? 1 : fromEnd === 1 ? 0.85 : fromEnd === 2 ? 0.7 : 0.6;
return (
<span key={i} style={{ opacity, transition: "opacity 0.3s ease" }}>
{line}
{i < total - 1 && "\n"}
</span>
);
})}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// fallback
}
}, [text]);
return (
<button
onClick={handleCopy}
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer btn-hover-lift"
title="复制文本"
>
{copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
</button>
);
}
interface ChatMessagesProps {
messages: ChatMessage[];
isLoading: boolean;
streamingText: string;
streamingImages: ImageAsset[];
statusText: string;
}
export function ChatMessages({
messages,
isLoading,
streamingText,
streamingImages,
statusText,
}: ChatMessagesProps) {
return (
<div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
{messages.map((msg) => (
<motion.div
key={msg.id}
variants={msg.role === "user" ? messageEnter : assistantEnter}
initial="hidden"
animate="visible"
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div className="flex flex-col items-start gap-0.5">
<div
className={`max-w-[90vw] md:max-w-[80vw] px-4 md:px-5 py-3 ${
msg.role === "user"
? "user-bubble bg-gradient-to-r from-[var(--accent)]/8 to-[var(--accent)]/15 text-[var(--text-primary)]"
: "rounded-2xl surface-2 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]/40"
}`}
>
{/* 多图参考(新格式) */}
{msg.refImageUrls && msg.refImageUrls.length > 0 && (
<div className="mb-2">
<div className="flex flex-wrap gap-1.5">
{msg.refImageUrls.map((url, i) => (
<img
key={i}
src={url}
alt={`参考图 ${i + 1}`}
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
/>
))}
</div>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block typo-micro" style={{ textTransform: "none" }}>
{msg.refImageUrls.length}
</span>
</div>
)}
{/* 兼容旧数据:单图 */}
{!msg.refImageUrls && msg.refImageUrl && (
<div className="mb-2">
<img
src={msg.refImageUrl}
alt="参考图"
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
/>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block"></span>
</div>
)}
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
{msg.images && msg.images.length > 0 && (
<>
<ImageGrid images={msg.images} />
{msg.modelName && (
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50 typo-caption">
{msg.modelName}
</div>
)}
</>
)}
</div>
{msg.content && (
<div className={`opacity-0 group-hover/msg:opacity-100 transition-opacity
${msg.role === "user" ? "mr-1 self-end" : "ml-1"}`}>
<CopyButton text={msg.content} />
</div>
)}
</div>
</motion.div>
))}
{/* AI 正在回复(流式) */}
<AnimatePresence>
{isLoading && (
<motion.div
key="streaming"
variants={assistantEnter}
initial="hidden"
animate="visible"
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="flex justify-start"
>
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 surface-2 border-l-2 border-l-[var(--accent)]/40">
{statusText && (
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
shadow-[0_0_8px_rgba(46,139,122,0.4)]" />
{statusText}
</div>
)}
{streamingText && (
<StreamingLines text={streamingText} />
)}
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
{!streamingText && !statusText && (
<div className="flex items-end gap-1 h-5">
<div className="wave-dot" style={{ animationDelay: "0s" }} />
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}