diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index bcbe69272..b0106fd8c 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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(null); // 从 API 加载历史消息 const { data: historyData } = useSWR<{ messages: ChatMessage[]; }>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher); + const { data: messageVotes } = useSWR( + 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( + 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 (
@@ -4679,90 +4834,153 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
) : ( -
- {/* 消息内容 */} -
- {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 ( -
- {textWithoutImages && ( - - {textWithoutImages} - - )} - {imageUrls.length > 0 && ( -
- {imageUrls.map((url, idx) => ( -
- setFullscreenPreview({ url })} - onDoubleClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setFullscreenPreview({ url }); - }} - /> + <> +
+ {/* 消息内容 */} +
+ {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) => ( +
+ {text} +
+ ); + + return ( +
+ {analysisSection ? ( +
+ {analysisSection.before + ? renderPlainText(analysisSection.before, `before-${i}`) + : null} +
+
+ + + Q. + + + 图片分析 + + + +
+
+ {analysisSection.analysis} +
- ))} -
- )} - {videoUrls.length > 0 && ( -
0 ? "mt-3" : "mt-0")}> - {videoUrls.map((url, idx) => ( - - ); - } - return null; - })} + setFullscreenPreview({ url })} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setFullscreenPreview({ url }); + }} + /> +
+ ))} +
+ )} + {videoUrls.length > 0 && ( +
0 ? "mt-3" : "mt-0")}> + {videoUrls.map((url, idx) => ( + + ))} +
+ )} +
+ ); + } + return null; + })} +
-
+
+ + +
+ )}
@@ -4781,9 +4999,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF {isLoading && !shouldShowImageLoading && (
-
- - 正在思考中... +
+ + 思考中
)} diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx index 67c959f61..1dc3bf142 100644 --- a/components/image-generation-loading.tsx +++ b/components/image-generation-loading.tsx @@ -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 = { + "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 ( -
-
- {/* 头像 */} -
-
-
-
-
- -
-
-
+const PHASE_TITLE: Record, string> = { + idle: "准备中", + queued: "排队中", + processing: "生成中", + finalizing: "加载结果", + failed: "生成失败", +}; -
- {/* 文本提示 */} -
- - {phaseTitle} - - - - - - +const PHASE_HINT: Record, 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 ( +
+
+
+ + 开始生成
- {/* 图片占位符 - 横向排列,每个 170x200px */} -
- {Array.from({ length: count }, (_, index) => index).map((index) => ( -
- {/* 底层渐变背景 */} -
- - {/* 动态模糊叠加层 1 */} -
- - {/* 动态模糊叠加层 2 */} -
- - {/* 噪点纹理效果 */} -
- - {/* 模糊叠加 */} -
- - {/* 中心图标 */} -
-
- {/* 外圈光晕 */} -
- {/* 图片图标 */} - - - -
+
+ 现在开始生成设计图,使用 {resolvedModelName} 模型,预计需要 {estimateMinutes} 分钟。 +
+ +
+ + {resolvedModelName} +
+ +
{PHASE_TITLE[phase]}
+ +
1 && "grid-cols-2" + )} + > + {count === 1 ? ( + <> +
+
+ + ) : ( + Array.from({ length: count }).map((_, index) => ( +
+
+
- - {/* 流光效果 */} -
-
- ))} + )) + )}
- - {/* 提示文字 */} -
- {phaseHint} - {elapsedLabel ? {elapsedLabel} : null} + +
+ + {isFailed ? "生成失败" : "设计图生成中"} +
+ +
+
+ 使用 {resolvedModelName} 生成图片,预计 {estimateSeconds} +
+ {(phase !== "processing" || elapsedSeconds > 0) && ( +
+ {PHASE_HINT[phase]} {elapsedLabel} +
+ )}
-
- - {/* 流光动画样式 */} - -
- ); -}); + `} +
+ ); + } +); ImageGenerationLoading.displayName = "ImageGenerationLoading"; - -export default ImageGenerationLoading;