187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
"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>
|
||
);
|
||
}
|