feat: polish canvas chat UI and loading states
This commit is contained in:
parent
4a0880a4c5
commit
de955db82f
@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useSWR from "swr";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain } from "lucide-react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@ -184,6 +184,95 @@ function formatMessageDateLabel(timestamp: number): string {
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析";
|
||||
const CANVAS_AGENT_SECTION_TITLES = new Set([
|
||||
"需求理解",
|
||||
CANVAS_IMAGE_ANALYSIS_TITLE,
|
||||
"执行计划",
|
||||
"输出设置",
|
||||
]);
|
||||
|
||||
type CanvasAnalysisSection = {
|
||||
before: string;
|
||||
analysis: string;
|
||||
after: string;
|
||||
};
|
||||
|
||||
function normalizeCanvasAgentText(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/^#{1,6}\s*/gm, "")
|
||||
.replace(/^\*\*(.+)\*\*$/gm, "$1")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeCanvasSectionTitle(line: string): string {
|
||||
let normalized = line.trim();
|
||||
if (!normalized) return "";
|
||||
|
||||
normalized = normalized
|
||||
.replace(/^#{1,6}\s*/, "")
|
||||
.replace(/^[-*]\s*/, "")
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.replace(/^【(.+)】$/, "$1")
|
||||
.replace(/[::]+$/, "")
|
||||
.trim();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractCanvasImageAnalysisSection(content: string): CanvasAnalysisSection | null {
|
||||
const lines = String(content || "").split(/\r?\n/);
|
||||
let startIndex = -1;
|
||||
let inlineAnalysis = "";
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const title = normalizeCanvasSectionTitle(lines[i]);
|
||||
if (!title || !title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
|
||||
|
||||
startIndex = i;
|
||||
const headingText = lines[i]
|
||||
.trim()
|
||||
.replace(/^#{1,6}\s*/, "")
|
||||
.replace(/^[-*]\s*/, "")
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.replace(/^【(.+)】$/, "$1")
|
||||
.trim();
|
||||
const inlineMatch = headingText.match(/^图片分析[::]\s*(.+)$/);
|
||||
if (inlineMatch?.[1]) {
|
||||
inlineAnalysis = inlineMatch[1].trim();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
let endIndex = lines.length;
|
||||
for (let i = startIndex + 1; i < lines.length; i += 1) {
|
||||
const title = normalizeCanvasSectionTitle(lines[i]);
|
||||
if (!title || title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
|
||||
if (CANVAS_AGENT_SECTION_TITLES.has(title)) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const analysisLines = lines.slice(startIndex + 1, endIndex);
|
||||
if (inlineAnalysis) {
|
||||
analysisLines.unshift(inlineAnalysis);
|
||||
}
|
||||
|
||||
const analysis = normalizeCanvasAgentText(analysisLines.join("\n"));
|
||||
if (!analysis) return null;
|
||||
|
||||
return {
|
||||
before: normalizeCanvasAgentText(lines.slice(0, startIndex).join("\n")),
|
||||
analysis,
|
||||
after: normalizeCanvasAgentText(lines.slice(endIndex).join("\n")),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoParamsForProvider(
|
||||
params: VideoGenerationParams | null | undefined,
|
||||
providerType?: string
|
||||
@ -311,6 +400,12 @@ interface PickedObjectAttachment extends Attachment {
|
||||
};
|
||||
}
|
||||
|
||||
type MessageVote = {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
isUpvoted: boolean;
|
||||
};
|
||||
|
||||
interface CanvasChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -990,17 +1085,73 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
|
||||
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
|
||||
const rawChatId = toRawCanvasChatId(activeChatId);
|
||||
const voteSWRKey = rawChatId ? `/api/vote?chatId=${rawChatId}` : null;
|
||||
const { mutate } = useSWRConfig();
|
||||
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
|
||||
|
||||
// 从 API 加载历史消息
|
||||
const { data: historyData } = useSWR<{
|
||||
messages: ChatMessage[];
|
||||
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
|
||||
const { data: messageVotes } = useSWR<MessageVote[]>(
|
||||
voteSWRKey,
|
||||
fetcher
|
||||
);
|
||||
|
||||
// 获取选中的图片节点
|
||||
const selectedImageNodes = nodes.filter(
|
||||
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
|
||||
);
|
||||
|
||||
const handleAssistantVote = useCallback(
|
||||
async (messageId: string, type: "up" | "down") => {
|
||||
if (!rawChatId) return;
|
||||
|
||||
setVotingMessageId(messageId);
|
||||
try {
|
||||
const response = await fetch("/api/vote", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
chatId: rawChatId,
|
||||
messageId,
|
||||
type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Vote failed");
|
||||
}
|
||||
|
||||
mutate<MessageVote[]>(
|
||||
voteSWRKey,
|
||||
(currentVotes) => {
|
||||
const baseVotes = Array.isArray(currentVotes) ? currentVotes : [];
|
||||
const filteredVotes = baseVotes.filter(
|
||||
(vote) => vote.messageId !== messageId
|
||||
);
|
||||
return [
|
||||
...filteredVotes,
|
||||
{
|
||||
chatId: rawChatId,
|
||||
messageId,
|
||||
isUpvoted: type === "up",
|
||||
},
|
||||
];
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
|
||||
toast.success(type === "up" ? "已点赞" : "已点踩");
|
||||
} catch (error) {
|
||||
console.error("[CanvasChat] vote failed:", error);
|
||||
toast.error(type === "up" ? "点赞失败,请稍后重试" : "点踩失败,请稍后重试");
|
||||
} finally {
|
||||
setVotingMessageId(null);
|
||||
}
|
||||
},
|
||||
[mutate, rawChatId, voteSWRKey]
|
||||
);
|
||||
|
||||
const getCanvasNodeAttachment = useCallback(
|
||||
(node: any, fallbackName: string): PickedObjectAttachment | null => {
|
||||
const src = (node?.data as any)?.src;
|
||||
@ -4578,6 +4729,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
(!prevMessageTimestamp ||
|
||||
getMessageDayKey(prevMessageTimestamp) !== getMessageDayKey(messageTimestamp))
|
||||
);
|
||||
const currentVote = messageVotes?.find(
|
||||
(vote) => vote.messageId === message.id
|
||||
);
|
||||
const isVotingCurrentMessage = votingMessageId === message.id;
|
||||
|
||||
return (
|
||||
<div key={message.id} className="space-y-3">
|
||||
@ -4679,90 +4834,153 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
|
||||
isImageOnlyAssistant
|
||||
? "bg-transparent px-0 py-0"
|
||||
: "bg-transparent px-0 py-0"
|
||||
)}
|
||||
>
|
||||
{/* 消息内容 */}
|
||||
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
|
||||
{message.parts?.map((part: any, i: number) => {
|
||||
if (part.type === 'text') {
|
||||
// 提取并显示图片/视频
|
||||
const rawText = part.text || '';
|
||||
const imageUrls = extractImageUrls(rawText);
|
||||
const videoUrls = extractVideoUrls(rawText);
|
||||
const textWithoutImages = stripMediaMarkup(rawText);
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
{textWithoutImages && (
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{textWithoutImages}
|
||||
</span>
|
||||
)}
|
||||
{imageUrls.length > 0 && (
|
||||
<div className={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"relative group overflow-hidden border transition-all",
|
||||
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
|
||||
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
style={{ maxHeight: "260px" }}
|
||||
onClick={() => setFullscreenPreview({ url })}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFullscreenPreview({ url });
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
|
||||
isImageOnlyAssistant
|
||||
? "bg-transparent px-0 py-0"
|
||||
: "bg-transparent px-0 py-0"
|
||||
)}
|
||||
>
|
||||
{/* 消息内容 */}
|
||||
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
|
||||
{message.parts?.map((part: any, i: number) => {
|
||||
if (part.type === 'text') {
|
||||
// 提取并显示图片/视频
|
||||
const rawText = part.text || '';
|
||||
const imageUrls = extractImageUrls(rawText);
|
||||
const videoUrls = extractVideoUrls(rawText);
|
||||
const textWithoutImages = stripMediaMarkup(rawText);
|
||||
const analysisSection = extractCanvasImageAnalysisSection(textWithoutImages);
|
||||
const plainText = normalizeCanvasAgentText(textWithoutImages);
|
||||
const renderPlainText = (text: string, key: string) => (
|
||||
<div
|
||||
key={key}
|
||||
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
{analysisSection ? (
|
||||
<div className="space-y-3">
|
||||
{analysisSection.before
|
||||
? renderPlainText(analysisSection.before, `before-${i}`)
|
||||
: null}
|
||||
<div className="my-2 rounded-[20px] bg-[#f4f5f7] px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-white text-[11px] font-semibold text-[#98a2b3] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.45)]">
|
||||
Q.
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight text-[#9aa1ad]">
|
||||
图片分析
|
||||
</span>
|
||||
</span>
|
||||
<ChevronUp className="h-4 w-4 text-[#9aa1ad]" />
|
||||
</div>
|
||||
<div className="pt-3 text-[15px] font-medium leading-7 text-[#2f3640]">
|
||||
{analysisSection.analysis}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{videoUrls.length > 0 && (
|
||||
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
|
||||
{videoUrls.map((url, idx) => (
|
||||
<div
|
||||
key={`video-${idx}`}
|
||||
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
|
||||
>
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="block w-full rounded-xl bg-black"
|
||||
style={{ maxHeight: "320px" }}
|
||||
/>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
|
||||
{analysisSection.after
|
||||
? renderPlainText(analysisSection.after, `after-${i}`)
|
||||
: null}
|
||||
</div>
|
||||
) : plainText ? (
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{plainText}
|
||||
</span>
|
||||
) : null}
|
||||
{imageUrls.length > 0 && (
|
||||
<div className={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"relative group overflow-hidden border transition-all",
|
||||
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
|
||||
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
|
||||
)}
|
||||
>
|
||||
下载视频
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
style={{ maxHeight: "260px" }}
|
||||
onClick={() => setFullscreenPreview({ url })}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFullscreenPreview({ url });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{videoUrls.length > 0 && (
|
||||
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
|
||||
{videoUrls.map((url, idx) => (
|
||||
<div
|
||||
key={`video-${idx}`}
|
||||
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
|
||||
>
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="block w-full rounded-xl bg-black"
|
||||
style={{ maxHeight: "320px" }}
|
||||
/>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
下载视频
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[#8f96a3]">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
|
||||
currentVote?.isUpvoted && "text-[#22a06b] bg-emerald-50/80"
|
||||
)}
|
||||
title="点赞"
|
||||
disabled={isVotingCurrentMessage}
|
||||
onClick={() => void handleAssistantVote(message.id, "up")}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
|
||||
currentVote && !currentVote.isUpvoted && "text-[#d94848] bg-rose-50/80"
|
||||
)}
|
||||
title="点踩"
|
||||
disabled={isVotingCurrentMessage}
|
||||
onClick={() => void handleAssistantVote(message.id, "down")}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -4781,9 +4999,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
|
||||
{isLoading && !shouldShowImageLoading && (
|
||||
<div className="flex w-full justify-start">
|
||||
<div className="rounded-xl bg-[#f1f2f4] px-3 py-2 text-card-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">正在思考中...</span>
|
||||
<div className="flex items-center gap-2 text-[#8e97a5]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-[16px]">思考中</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,283 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { ChevronDown, Grid2x2, CircleDot } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 预定义的颜色主题
|
||||
const COLOR_THEMES = {
|
||||
purple: {
|
||||
ping: "bg-purple-500/20",
|
||||
pulse: "bg-purple-500/10",
|
||||
ring: "ring-purple-500/30",
|
||||
text: "text-purple-500",
|
||||
dot: "bg-purple-500",
|
||||
gradientFrom: "from-purple-400",
|
||||
gradientVia: "via-pink-400",
|
||||
gradientTo: "to-blue-400",
|
||||
overlay1From: "from-purple-500/40",
|
||||
overlay1To: "to-pink-500/40",
|
||||
overlay2From: "from-blue-500/30",
|
||||
overlay2To: "to-purple-500/30",
|
||||
},
|
||||
blue: {
|
||||
ping: "bg-blue-500/20",
|
||||
pulse: "bg-blue-500/10",
|
||||
ring: "ring-blue-500/30",
|
||||
text: "text-blue-500",
|
||||
dot: "bg-blue-500",
|
||||
gradientFrom: "from-blue-400",
|
||||
gradientVia: "via-cyan-400",
|
||||
gradientTo: "to-teal-400",
|
||||
overlay1From: "from-blue-500/40",
|
||||
overlay1To: "to-cyan-500/40",
|
||||
overlay2From: "from-teal-500/30",
|
||||
overlay2To: "to-blue-500/30",
|
||||
},
|
||||
green: {
|
||||
ping: "bg-green-500/20",
|
||||
pulse: "bg-green-500/10",
|
||||
ring: "ring-green-500/30",
|
||||
text: "text-green-500",
|
||||
dot: "bg-green-500",
|
||||
gradientFrom: "from-green-400",
|
||||
gradientVia: "via-emerald-400",
|
||||
gradientTo: "to-teal-400",
|
||||
overlay1From: "from-green-500/40",
|
||||
overlay1To: "to-emerald-500/40",
|
||||
overlay2From: "from-teal-500/30",
|
||||
overlay2To: "to-green-500/30",
|
||||
},
|
||||
orange: {
|
||||
ping: "bg-orange-500/20",
|
||||
pulse: "bg-orange-500/10",
|
||||
ring: "ring-orange-500/30",
|
||||
text: "text-orange-500",
|
||||
dot: "bg-orange-500",
|
||||
gradientFrom: "from-orange-400",
|
||||
gradientVia: "via-amber-400",
|
||||
gradientTo: "to-yellow-400",
|
||||
overlay1From: "from-orange-500/40",
|
||||
overlay1To: "to-amber-500/40",
|
||||
overlay2From: "from-yellow-500/30",
|
||||
overlay2To: "to-orange-500/30",
|
||||
},
|
||||
red: {
|
||||
ping: "bg-red-500/20",
|
||||
pulse: "bg-red-500/10",
|
||||
ring: "ring-red-500/30",
|
||||
text: "text-red-500",
|
||||
dot: "bg-red-500",
|
||||
gradientFrom: "from-red-400",
|
||||
gradientVia: "via-rose-400",
|
||||
gradientTo: "to-pink-400",
|
||||
overlay1From: "from-red-500/40",
|
||||
overlay1To: "to-rose-500/40",
|
||||
overlay2From: "from-pink-500/30",
|
||||
overlay2To: "to-red-500/30",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ColorTheme = keyof typeof COLOR_THEMES;
|
||||
type ColorTheme = "purple" | "blue" | "green" | "orange" | "red";
|
||||
|
||||
interface ImageGenerationLoadingProps {
|
||||
imageCount?: number; // 生成图片数量,默认 1
|
||||
maxImages?: number; // 最大图片数限制,默认 6
|
||||
colorTheme?: ColorTheme; // 颜色主题,默认 purple
|
||||
modelName?: string; // 模型名称,用于显示
|
||||
providerType?: string; // 提供商类型,用于自动选择颜色
|
||||
imageCount?: number;
|
||||
maxImages?: number;
|
||||
colorTheme?: ColorTheme;
|
||||
modelName?: string;
|
||||
providerType?: string;
|
||||
phase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
|
||||
elapsedSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用图片生成加载占位符组件
|
||||
* 支持配置颜色主题和图片数量
|
||||
*/
|
||||
export const ImageGenerationLoading = memo(({
|
||||
imageCount = 1,
|
||||
maxImages = 6,
|
||||
colorTheme,
|
||||
modelName,
|
||||
providerType,
|
||||
phase = "processing",
|
||||
elapsedSeconds = 0,
|
||||
}: ImageGenerationLoadingProps) => {
|
||||
// 根据 providerType 自动选择颜色主题
|
||||
const getAutoColorTheme = (): ColorTheme => {
|
||||
if (colorTheme) return colorTheme;
|
||||
switch (providerType) {
|
||||
case "gemini":
|
||||
return "purple";
|
||||
case "alibaba-image":
|
||||
return "orange";
|
||||
case "ruxa-image":
|
||||
return "green";
|
||||
case "fal-image":
|
||||
return "blue";
|
||||
case "volcano-vision":
|
||||
return "blue";
|
||||
default:
|
||||
return "purple";
|
||||
}
|
||||
};
|
||||
|
||||
const theme = COLOR_THEMES[getAutoColorTheme()];
|
||||
|
||||
// 确保数量在有效范围内
|
||||
const count = Math.min(Math.max(imageCount, 1), maxImages);
|
||||
|
||||
// 获取显示名称
|
||||
const displayName = modelName || (providerType === "alibaba-image" ? "千问图片编辑" :
|
||||
providerType === "ruxa-image" ? "RUXA 图片编辑" :
|
||||
providerType === "fal-image" ? "FAL 图片编辑" :
|
||||
providerType === "volcano-vision" ? "即梦4.5" : "AI");
|
||||
const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
||||
"alibaba-image": "千问图片",
|
||||
"ruxa-image": "RUXA 图片",
|
||||
"fal-image": "FAL 图片",
|
||||
"volcano-vision": "即梦 4.5",
|
||||
gemini: "Gemini 图片",
|
||||
};
|
||||
|
||||
const phaseTitle =
|
||||
phase === "queued"
|
||||
? "正在排队准备生成"
|
||||
: phase === "finalizing"
|
||||
? "图片生成完成,正在加载结果"
|
||||
: "正在生成图片";
|
||||
const phaseHint =
|
||||
phase === "queued"
|
||||
? `${displayName} 正在排队,请稍候...`
|
||||
: phase === "finalizing"
|
||||
? `${displayName} 正在同步结果到聊天记录...`
|
||||
: `${displayName} 正在创作 ${count} 张图片,请稍候...`;
|
||||
const elapsedLabel = elapsedSeconds > 0 ? `已等待 ${elapsedSeconds} 秒` : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-300"
|
||||
data-role="assistant"
|
||||
data-testid="message-assistant-image-loading"
|
||||
>
|
||||
<div className="flex items-start justify-start gap-3">
|
||||
{/* 头像 */}
|
||||
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
|
||||
<div className={`absolute inset-0 rounded-full ${theme.ping} animate-ping`} />
|
||||
<div className={`absolute inset-0 rounded-full ${theme.pulse} animate-pulse`} />
|
||||
<div className={`relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ${theme.ring}`}>
|
||||
<div className={`animate-spin ${theme.text}`}>
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const PHASE_TITLE: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, string> = {
|
||||
idle: "准备中",
|
||||
queued: "排队中",
|
||||
processing: "生成中",
|
||||
finalizing: "加载结果",
|
||||
failed: "生成失败",
|
||||
};
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 文本提示 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-base font-medium text-foreground">
|
||||
{phaseTitle}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</span>
|
||||
const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, string> = {
|
||||
idle: "任务已创建,正在初始化生成参数。",
|
||||
queued: "任务已进入队列,请稍候。",
|
||||
processing: "模型正在生成图片,请稍候。",
|
||||
finalizing: "图片已生成完成,正在同步到对话。",
|
||||
failed: "生成失败,请重试。",
|
||||
};
|
||||
|
||||
export const ImageGenerationLoading = memo(
|
||||
({
|
||||
imageCount = 1,
|
||||
maxImages = 6,
|
||||
modelName,
|
||||
providerType,
|
||||
phase = "processing",
|
||||
elapsedSeconds = 0,
|
||||
}: ImageGenerationLoadingProps) => {
|
||||
const count = Math.min(Math.max(imageCount, 1), maxImages);
|
||||
const resolvedModelName =
|
||||
modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型";
|
||||
const estimateSeconds = Math.max(45, Math.min(180, count * 60));
|
||||
const estimateMinutes = Math.max(1, Math.ceil(estimateSeconds / 60));
|
||||
const elapsedLabel =
|
||||
elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateSeconds}s`;
|
||||
const isFailed = phase === "failed";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-300"
|
||||
data-role="assistant"
|
||||
data-testid="message-assistant-image-loading"
|
||||
>
|
||||
<div className="w-full max-w-[760px] space-y-3">
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9aa3b2]">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<span className="font-medium">开始生成</span>
|
||||
</div>
|
||||
|
||||
{/* 图片占位符 - 横向排列,每个 170x200px */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{Array.from({ length: count }, (_, index) => index).map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative overflow-hidden rounded-xl"
|
||||
style={{ width: "170px", height: "200px" }}
|
||||
>
|
||||
{/* 底层渐变背景 */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${theme.gradientFrom} ${theme.gradientVia} ${theme.gradientTo} opacity-60`} />
|
||||
|
||||
{/* 动态模糊叠加层 1 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-tr ${theme.overlay1From} via-transparent ${theme.overlay1To} animate-pulse`}
|
||||
style={{ animationDuration: "2s", animationDelay: `${index * 0.3}s` }}
|
||||
/>
|
||||
|
||||
{/* 动态模糊叠加层 2 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-bl ${theme.overlay2From} via-transparent ${theme.overlay2To} animate-pulse`}
|
||||
style={{ animationDuration: "2.5s", animationDelay: `${0.5 + index * 0.3}s` }}
|
||||
/>
|
||||
|
||||
{/* 噪点纹理效果 */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 模糊叠加 */}
|
||||
<div className="absolute inset-0 backdrop-blur-sm" />
|
||||
|
||||
{/* 中心图标 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative">
|
||||
{/* 外圈光晕 */}
|
||||
<div className="absolute -inset-4 rounded-full bg-white/20 animate-ping" style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }} />
|
||||
{/* 图片图标 */}
|
||||
<svg
|
||||
className="h-10 w-10 text-white/80 animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-[16px] leading-8 text-[#3a4352]">
|
||||
现在开始生成设计图,使用 {resolvedModelName} 模型,预计需要 {estimateMinutes} 分钟。
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[#a8b1bf]">
|
||||
<CircleDot className="h-3.5 w-3.5 fill-current" />
|
||||
<span className="text-[16px]">{resolvedModelName}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-[42px] font-semibold text-[#37404d]">{PHASE_TITLE[phase]}</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-[22px] border border-[#e1e4ea] bg-[#eaedf2]",
|
||||
count === 1 ? "h-[620px] w-full max-w-[680px]" : "grid gap-3 p-3",
|
||||
count > 1 && "grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{count === 1 ? (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#e9ecf1] via-[#dfe4eb] to-[#e9ecf1] opacity-90" />
|
||||
<div className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.45)_48%,transparent_100%)]" />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative h-[220px] overflow-hidden rounded-2xl border border-[#dfe3e9] bg-[#e7ebf0]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#e7ebf0] via-[#dce2ea] to-[#e7ebf0]" />
|
||||
<div
|
||||
className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.42)_48%,transparent_100%)]"
|
||||
style={{ animationDelay: `${index * 0.2}s` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 流光效果 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
|
||||
style={{
|
||||
animation: "shimmer 2s infinite",
|
||||
animationDelay: `${index * 0.4}s`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{phaseHint}</span>
|
||||
{elapsedLabel ? <span className="text-foreground/80">{elapsedLabel}</span> : null}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1 text-[18px] text-[#8c95a3]">
|
||||
<Grid2x2 className="h-5 w-5" />
|
||||
<span className="font-medium">{isFailed ? "生成失败" : "设计图生成中"}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 text-[#a0a8b6]">
|
||||
<div className="text-[15px] font-medium">
|
||||
使用 {resolvedModelName} 生成图片,预计 {estimateSeconds}
|
||||
</div>
|
||||
{(phase !== "processing" || elapsedSeconds > 0) && (
|
||||
<div className="text-[13px]">
|
||||
{PHASE_HINT[phase]} {elapsedLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流光动画样式 */}
|
||||
<style jsx>{`
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
|
||||
<style jsx>{`
|
||||
.generation-skeleton-shimmer {
|
||||
transform: translateX(-100%);
|
||||
animation: generation-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
|
||||
@keyframes generation-shimmer {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(110%);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ImageGenerationLoading.displayName = "ImageGenerationLoading";
|
||||
|
||||
export default ImageGenerationLoading;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user