-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{imageCount}
+
+
+
+
+ AI 对话
+
+
+
+ 快捷工具
+
+
+
+ {/* AI 对话标签页 */}
+
+
+
+
+ {/* 快捷工具标签页 */}
+
+
+
+ {/* AI 图片生成 */}
+
+
+
+
AI 图片生成
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {imageCount}
+
+
setImageCount(v)}
+ min={1}
+ max={4}
+ step={1}
+ />
+
+
+
-
setImageCount(v)}
- min={1}
- max={4}
- step={1}
- />
-
-
-
-
- {/* 图片编辑工具 */}
-
-
-
-
图片编辑工具
-
-
- {selectedImageNode ? (
-
-
-
- 抠图
-
-
-
- 变清晰
-
-
-
- 扩展
-
-
-
- 调整
-
-
-
- 角度
-
-
-
- 场景
-
- ) : (
-
- 选中图片节点以使用编辑工具
-
- )}
-
-
+
- {/* 快捷操作 */}
-
-
-
-
快捷操作
+ {/* 快捷操作 */}
+
+
+
+
快捷操作
+
+
+
+
+
+ 添加图片
+
+
+
+ 添加文本
+
+
+
+ 上传图片
+
+
+
+ 导出
+
+
+
-
-
-
-
- 添加图片
-
-
-
- 添加文本
-
-
-
- 上传图片
-
-
-
- 导出
-
-
-
-
-
+
+
+
)}
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
new file mode 100644
index 000000000..f06e5a04f
--- /dev/null
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -0,0 +1,450 @@
+"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 图片格式: 
+ 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) => (
+
+

+
removeAttachment(index)}
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* 输入区域 */}
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}