"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 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; 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[]; } // 从消息中提取图片 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([]); const [selectedModelId, setSelectedModelId] = useState(""); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(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({ 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>(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) => { 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 (
{/* 模型选择 */}
{/* 消息列表 */}
{messages.length === 0 && (

开始对话生成图片

生成的图片将自动添加到画布

)} {messages.map((message) => (
{/* 附件预览 */} {message.attachments && message.attachments.length > 0 && (
{message.attachments.map((att: any, i: number) => ( ))}
)} {/* 消息内容 */}
{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 (
{textWithoutImages && {textWithoutImages}} {imageUrls.length > 0 && (
{imageUrls.map((url, idx) => ( window.open(url, '_blank')} /> ))}
)}
); } return null; })}
))} {isLoading && (
生成中...
)}
{/* 附件预览 */} {attachments.length > 0 && (
{attachments.map((att, index) => (
))}
)} {/* 输入区域 */}