lovart3/components/canvas/CanvasChatPanel.tsx

546 lines
20 KiB
TypeScript

"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";
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
}
// 建议卡片数据 - 已移除
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 = [] }: 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);
// 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 imageModels = modelsData?.models?.filter(m => m.supportsImageGen) || [];
// 默认选择第一个图片模型
useEffect(() => {
if (imageModels.length > 0 && !selectedModelId) {
setSelectedModelId(imageModels[0].id);
}
}, [imageModels, selectedModelId]);
const currentModelIdRef = useRef(selectedModelId);
useEffect(() => {
currentModelIdRef.current = selectedModelId;
}, [selectedModelId]);
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) {
return {
body: {
id: request.id,
message: request.messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: "private",
...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 handleSend = useCallback(() => {
if (!input.trim() && attachments.length === 0) return;
if (!selectedModelId) {
toast.error("请选择一个模型");
return;
}
const messageAttachments = attachments.map(a => ({
contentType: a.contentType,
url: a.url,
name: a.name,
}));
sendMessage({
role: "user" as const,
parts: [{ type: "text", text: input }],
attachments: messageAttachments,
});
setInput("");
setAttachments([]);
}, [input, attachments, selectedModelId, sendMessage]);
// 处理文件选择
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
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();
setAttachments(prev => [...prev, {
url: data.url,
name: file.name,
contentType: file.type,
}]);
} catch (error) {
toast.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">
{/* Header - Minimal with Icons */}
<div className="flex items-center justify-end gap-1 p-3 border-b bg-background/80 backdrop-blur-md sticky top-0 z-10">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Share2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<LayoutGrid className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Maximize2 className="h-4 w-4" />
</Button>
</div>
{/* 消息列表 */}
<ScrollArea className="flex-1 p-4">
<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 - Removed */}
</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 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all">
<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">
{/* Style/Settings Icons (Visual only for now) */}
<div className="flex items-center gap-1 mr-2 border-r pr-2">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground">
<Zap className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground">
<Globe className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground">
<Box className="h-4 w-4" />
</Button>
</div>
<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>
);
}