lovart3/components/canvas/CanvasChatPanel.tsx
Your Name e0366c43fd fix: 上传图片立即添加到画布
1. 图片上传成功后立即显示在画布中
2. 同时触发自动命名和设置缩略图
3. 移除发送时重复添加图片的逻辑

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-01 12:45:41 +08:00

735 lines
26 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 { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback } from "react";
import useSWR from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles, User, Bot, Plus, History, Share2, LayoutGrid, Maximize2, RefreshCw, Zap, Globe, Box, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import type { Attachment, ChatMessage } from "@/lib/types";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
onClose?: () => void;
onOpenFileList?: () => void;
}
// 建议卡片数据 - 已移除
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
// 从消息中提取图片 URL
function extractImageUrls(text: string): string[] {
const urls: string[] = [];
// 匹配 markdown 图片格式: ![alt|||originalUrl|||size](thumbnailUrl)
const markdownRegex = /!\[(?:thumb)?\|\|\|([^|]+)\|\|\|[^\]]*\]\([^)]+\)/g;
let match;
while ((match = markdownRegex.exec(text)) !== null) {
if (match[1]) {
urls.push(match[1]);
}
}
// 匹配普通图片 URL
const urlRegex = /https?:\/\/[^\s"'<>]+\.(?:jpg|jpeg|png|gif|webp)/gi;
const urlMatches = text.match(urlRegex);
if (urlMatches) {
urls.push(...urlMatches.filter(url => !urls.includes(url)));
}
return urls;
}
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 🆕 图片参数状态
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
// 🆕 保存图片参数到 localStorage
const handleImageParamsChange = (params: ImageGenerationParams) => {
setImageParams(params);
try {
localStorage.setItem("canvas-image-params", JSON.stringify(params));
} catch (e) {
console.error("Failed to save image params:", e);
}
};
// 加载图片参数
useEffect(() => {
try {
const saved = localStorage.getItem("canvas-image-params");
if (saved) {
const parsed = JSON.parse(saved);
setImageParams(prev => ({ ...prev, ...parsed }));
}
} catch (e) {
console.error("Failed to load image params:", e);
}
}, []);
// Canvas store
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore((state) => state.nodes);
// 获取模型列表
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
modelId: string;
displayName: string;
providerType: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
}>;
}>("/api/models", fetcher);
// 获取网站设置(包含广告横幅配置)
const { data: siteSettings } = useSWR<{
bannerEnabled?: boolean;
bannerText?: string;
bannerLink?: string | null;
}>("/api/site-settings", fetcher);
// 过滤支持图片生成的模型
const imageModels = modelsData?.models?.filter(m => m.supportsImageGen) || [];
// 默认选择第一个图片模型
useEffect(() => {
if (imageModels.length > 0 && !selectedModelId) {
setSelectedModelId(imageModels[0].id);
}
}, [imageModels, selectedModelId]);
const currentModelIdRef = useRef(selectedModelId);
const imageParamsRef = useRef(imageParams);
useEffect(() => {
currentModelIdRef.current = selectedModelId;
}, [selectedModelId]);
useEffect(() => {
imageParamsRef.current = imageParams;
}, [imageParams]);
const {
messages,
setMessages,
sendMessage,
status,
stop,
} = useChat<ChatMessage>({
id: chatId,
messages: initialMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '')
: request.id;
console.log("[CanvasChat] Preparing request:", {
id: rawId,
modelId: currentModelIdRef.current,
imageParams: imageParamsRef.current,
lastMessage: request.messages.at(-1),
});
return {
body: {
id: rawId,
message: request.messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: "private",
videoParams: undefined, // 必须传递,即使是 undefined
imageParams: imageParamsRef.current || undefined, // 传递图片参数
...request.body,
},
};
},
}),
onData: (dataPart: any) => {
// 处理后端返回的自定义消息
if (dataPart.type === "data-appendMessage") {
try {
const messageData = typeof dataPart.data === 'string'
? JSON.parse(dataPart.data)
: dataPart.data;
setMessages((prevMessages) => {
if (prevMessages.some(m => m.id === messageData.id)) {
return prevMessages;
}
const newMessage: any = {
id: messageData.id,
role: messageData.role,
content: messageData.parts?.[0]?.text || "",
createdAt: new Date(),
parts: messageData.parts || [],
attachments: [],
};
return [...prevMessages, newMessage];
});
} catch (e) {
console.error("[CanvasChat] Failed to parse data-appendMessage:", e);
}
}
},
onError: (error) => {
toast.error(error.message || "生成失败");
},
});
// 监听消息变化,提取图片并添加到画布
const processedMessageIds = useRef<Set<string>>(new Set());
useEffect(() => {
messages.forEach(message => {
if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) {
const content = message.parts?.map((p: any) => p.text || '').join('') || '';
const imageUrls = extractImageUrls(content);
if (imageUrls.length > 0) {
processedMessageIds.current.add(message.id);
// 为每张图片创建画布节点
imageUrls.forEach((url, index) => {
// 计算位置(避免重叠)
const existingNodes = nodes.length;
const x = 100 + ((existingNodes + index) % 4) * 320;
const y = 100 + Math.floor((existingNodes + index) / 4) * 320;
addNode({
type: 'image',
position: { x, y },
size: { width: 300, height: 300 },
data: {
src: url,
status: 'completed',
prompt: '对话生成',
},
connections: [],
});
});
if (imageUrls.length > 0) {
toast.success(`已将 ${imageUrls.length} 张图片添加到画布`);
}
}
}
});
}, [messages, addNode, nodes.length]);
// 滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
if (!input.trim() && attachments.length === 0) return;
if (!selectedModelId) {
toast.error("请选择一个模型");
return;
}
// 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致)
const parts: any[] = [];
// 添加文件部分(作为 file 类型的 part)
attachments.forEach((a) => {
parts.push({
type: "file" as const,
mediaType: a.contentType,
name: a.name,
url: a.url,
});
});
// 添加文本部分
const textContent = input.trim() || (attachments.length > 0 ? "请处理这张图片" : "");
if (textContent) {
parts.push({ type: "text" as const, text: textContent });
}
// 使用与 multimodal-input 相同的格式发送消息
sendMessage({
role: "user",
parts: parts,
});
setInput("");
setAttachments([]);
}, [input, attachments, selectedModelId, sendMessage]);
// 处理文件选择 - 上传后立即添加到画布
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
let firstImageUrl: string | null = null;
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
continue;
}
if (file.size > 8 * 1024 * 1024) {
toast.error('图片大小不能超过 8MB');
continue;
}
// 上传文件
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const data = await response.json();
const imageUrl = data.url;
// 记录第一张图片的 URL(用于自动命名和缩略图)
if (!firstImageUrl) {
firstImageUrl = imageUrl;
}
// 添加到附件列表
setAttachments(prev => [...prev, {
url: imageUrl,
name: file.name,
contentType: file.type,
}]);
// 立即将图片添加到画布
const existingNodes = nodes.length;
const x = 100 + (existingNodes % 4) * 320;
const y = 100 + Math.floor(existingNodes / 4) * 320;
addNode({
type: 'image',
position: { x, y },
size: { width: 300, height: 300 },
data: {
src: imageUrl,
status: 'completed',
prompt: '用户上传',
},
connections: [],
});
toast.success('图片已添加到画布');
} catch (error) {
toast.error('图片上传失败');
}
}
// 如果是第一次上传图片,自动识别并设置画布标题和缩略图
if (firstImageUrl && !hasSetCanvasTitle.current) {
hasSetCanvasTitle.current = true;
// 异步调用 AI 识别图片主体并更新画布
(async () => {
try {
// 调用 auto-title API 识别图片主体
const response = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: firstImageUrl }),
});
if (response.ok) {
const data = await response.json();
const title = data.title || '未命名画布';
// 提取纯 UUID(去掉 canvas- 前缀)
const rawId = chatId.startsWith('canvas-')
? chatId.replace('canvas-', '')
: chatId;
// 更新画布标题和缩略图
await fetch(`/api/canvas/${rawId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
thumbnail: firstImageUrl,
}),
});
console.log('[CanvasChat] 画布已自动命名为:', title);
toast.success(`画布已命名为: ${title}`);
}
} catch (error) {
console.error('[CanvasChat] 自动命名失败:', error);
}
})();
}
e.target.value = '';
};
// 移除附件
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
const isLoading = status === 'streaming' || status === 'submitted';
return (
<div className="flex flex-col h-full bg-background/50 backdrop-blur-sm relative">
{/* Header - Minimal with Icons */}
<div className="absolute top-0 right-0 z-20 p-4 flex flex-col items-end gap-2">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
<Share2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('[CanvasChatPanel] File list button clicked');
if (onOpenFileList) {
onOpenFileList();
}
}}
title="已生成文件列表"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full hover:bg-muted">
<Maximize2 className="h-4 w-4" />
</Button>
</div>
{onClose && (
<Button
variant="secondary"
size="sm"
onClick={onClose}
className="h-7 px-3 text-xs rounded-full bg-muted/80 backdrop-blur-sm hover:bg-muted shadow-sm"
>
收起
</Button>
)}
</div>
{/* 消息列表 */}
<ScrollArea className="flex-1 p-4 pt-16">
<div className="space-y-6">
{messages.length === 0 && (
<div className="flex flex-col h-full pt-4">
{/* Greeting */}
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Hi, 我是你的AI设计师</h1>
<p className="text-muted-foreground">让我们开始今天的创作吧!</p>
</div>
{/* Suggestions - Removed */}
{/* Refresh - Removed */}
{/* Banner - 从系统设置中获取 */}
{(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
<div
className={cn(
"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
)}
onClick={() => {
if (siteSettings?.bannerLink) {
window.open(siteSettings.bannerLink, '_blank');
}
}}
>
<div className="flex items-center gap-2">
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
<Box className="h-4 w-4" />
</div>
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
</div>
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
</div>
)}
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === 'user' ? "flex-row-reverse" : "flex-row"
)}
>
{/* 头像 */}
<Avatar className="h-8 w-8 shrink-0 border shadow-sm">
{message.role === 'user' ? (
<>
<AvatarImage src="/placeholder-user.jpg" />
<AvatarFallback className="bg-primary text-primary-foreground">
<User className="h-4 w-4" />
</AvatarFallback>
</>
) : (
<>
<AvatarImage src="/placeholder-bot.jpg" />
<AvatarFallback className="bg-muted text-foreground">
<Bot className="h-4 w-4" />
</AvatarFallback>
</>
)}
</Avatar>
{/* 消息气泡 */}
<div className={cn(
"flex flex-col gap-2 max-w-[85%]",
message.role === 'user' ? "items-end" : "items-start"
)}>
<div
className={cn(
"text-sm px-4 py-2.5 shadow-sm",
message.role === 'user'
? "bg-primary text-primary-foreground rounded-2xl rounded-tr-sm"
: "bg-card border text-card-foreground rounded-2xl rounded-tl-sm"
)}
>
{/* 附件预览 */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{message.attachments.map((att: any, i: number) => (
<img
key={i}
src={att.url}
alt=""
className="h-16 w-16 object-cover rounded-lg border bg-background"
/>
))}
</div>
)}
{/* 消息内容 */}
<div className="whitespace-pre-wrap break-words leading-relaxed">
{message.parts?.map((part: any, i: number) => {
if (part.type === 'text') {
// 提取并显示图片
const imageUrls = extractImageUrls(part.text || '');
const textWithoutImages = (part.text || '')
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, '')
.trim();
return (
<div key={i}>
{textWithoutImages && <span>{textWithoutImages}</span>}
{imageUrls.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative group rounded-xl overflow-hidden border bg-background shadow-sm transition-all hover:shadow-md">
<img
src={url}
alt=""
className="max-w-full h-auto cursor-pointer hover:scale-105 transition-transform duration-300"
style={{ maxHeight: '200px' }}
onClick={() => window.open(url, '_blank')}
/>
</div>
))}
</div>
)}
</div>
);
}
return null;
})}
</div>
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0 border shadow-sm">
<AvatarFallback className="bg-muted text-foreground">
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="bg-card border text-card-foreground rounded-2xl rounded-tl-sm px-4 py-2.5 shadow-sm flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">正在思考中...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* 底部输入区域 - Redesigned */}
<div className="p-4 bg-background/80 backdrop-blur-md border-t">
{/* 附件预览 */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{attachments.map((att, index) => (
<div key={index} className="relative group">
<img
src={att.url}
alt=""
className="h-14 w-14 object-cover rounded-lg border shadow-sm"
/>
<button
className="absolute -top-1.5 -right-1.5 bg-destructive text-destructive-foreground rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-all shadow-sm hover:scale-110"
onClick={() => removeAttachment(index)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="请输入你的设计需求"
className="min-h-[80px] max-h-[150px] w-full text-sm resize-none bg-transparent border-none focus-visible:ring-0 p-4 pb-12 placeholder:text-muted-foreground/50"
disabled={isLoading}
/>
{/* Bottom Toolbar */}
<div className="absolute left-2 bottom-2 right-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
<Paperclip className="h-4 w-4" />
</Button>
{/* Agent Selector (Model) */}
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-8 text-xs bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100 hover:border-blue-200 transition-colors rounded-full px-3 gap-1 w-auto">
<Sparkles className="h-3 w-3" />
<span className="truncate max-w-[80px]">
{imageModels.find(m => m.id === selectedModelId)?.displayName || "Agent"}
</span>
</SelectTrigger>
<SelectContent>
{imageModels.map(model => (
<SelectItem key={model.id} value={model.id} className="text-xs">
<div className="flex items-center gap-2">
<Sparkles className="h-3 w-3 text-primary" />
{model.displayName}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
{/* Image Params Bar */}
<ImageParamsBar
modelName={imageModels.find(m => m.id === selectedModelId)?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={imageModels.find(m => m.id === selectedModelId)?.providerType}
/>
<Button
size="icon"
className={cn(
"h-8 w-8 rounded-full transition-all duration-200",
input.trim() || attachments.length > 0
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
onClick={handleSend}
disabled={isLoading || (!input.trim() && attachments.length === 0)}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}