lovart3/components/canvas/CanvasChatPanel.tsx

2131 lines
82 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, useMemo } from "react";
import { createPortal } from "react-dom";
import useSWR from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Sparkles, User, Bot, Plus, History, 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";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
// 标签悬停预览信息
interface TagPreviewInfo {
url: string;
name: string;
x: number; // 标签中心x位置
y: number; // 标签顶部y位置
// 聊天面板容器边界
containerBounds?: {
left: number;
right: number;
top: number;
bottom: number;
};
// 物品标签额外信息
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
};
}
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
onClose?: () => void;
onOpenFileList?: () => void;
}
// 建议卡片数据 - 已移除
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// 限制最大尺寸,避免节点过大
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
// 如果图片太大,按比例缩小
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
// 设置最小尺寸
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
// 加载失败时使用默认尺寸
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
// 从消息中提取图片 URL(只提取原始图片,避免重复)
function extractImageUrls(text: string): string[] {
const urls: string[] = [];
// 匹配 markdown 图片格式: ![thumb|||originalUrl|||size](thumbnailUrl)
// 只提取原始图片 URL(第一个 ||| 和第二个 ||| 之间的 URL)
const markdownRegex = /!\[(?:thumb)?\|\|\|([^|]+)\|\|\|[^\]]*\]\(([^)]+)\)/g;
let match;
let hasMarkdownImages = false;
while ((match = markdownRegex.exec(text)) !== null) {
if (match[1]) {
urls.push(match[1]); // 原始图片 URL
hasMarkdownImages = true;
}
}
// 只有在没有 markdown 格式图片时,才匹配普通图片 URL
// 这样可以避免重复匹配缩略图 URL
if (!hasMarkdownImages) {
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;
}
function extractUserTextPrompt(message: ChatMessage): string {
const parts = (message as any)?.parts || [];
const textFromParts = parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) =>
part.text
.replace(
/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g,
""
)
.trim()
)
.filter(Boolean)
.join("\n")
.trim();
if (textFromParts) return textFromParts;
const content = (message as any)?.content;
return typeof content === "string" ? content.trim() : "";
}
function findNearestUserPrompt(messages: ChatMessage[], assistantIndex: number): string {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const candidate = messages[i];
if (candidate?.role !== "user") continue;
const prompt = extractUserTextPrompt(candidate);
if (prompt) return prompt;
}
return "";
}
const GENERIC_CANVAS_PROMPT_SET = new Set([
"对话生成",
"图片生成",
"AI生成",
"智能生成",
"自动生成",
]);
function isGenericCanvasPrompt(prompt: unknown): boolean {
if (typeof prompt !== "string") return true;
const normalized = prompt.trim();
if (!normalized) return true;
return GENERIC_CANVAS_PROMPT_SET.has(normalized);
}
// 🆕 拾取物品的附件类型扩展
interface PickedObjectAttachment extends Attachment {
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
boundingBox: { x: number; y: number; width: number; height: number };
};
}
interface CanvasImageAttachment {
url: string;
name: string;
contentType: string;
}
interface CanvasChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
const SESSION_TITLE_MAX_LEN = 18;
function toRawCanvasChatId(value: string): string {
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
}
function getSessionStorageKey(canvasId: string): string {
return `canvas-chat-sessions:${canvasId}`;
}
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
const prompt = extractUserTextPrompt(firstUserMessage).replace(/\s+/g, " ").trim();
if (!prompt) return "";
return prompt.length > SESSION_TITLE_MAX_LEN
? `${prompt.slice(0, SESSION_TITLE_MAX_LEN)}...`
: prompt;
}
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const canvasId = useMemo(() => toRawCanvasChatId(chatId), [chatId]);
const [activeChatId, setActiveChatId] = useState<string>(chatId);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
const sessionMenuRef = useRef<HTMLDivElement>(null);
// 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string;
alt?: string;
} | null>(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);
}
}, []);
useEffect(() => {
if (!fullscreenPreview) return;
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreenPreview(null);
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [fullscreenPreview]);
useEffect(() => {
const now = Date.now();
const fallbackSession: CanvasChatSession = {
id: chatId,
title: "对话 1",
createdAt: now,
updatedAt: now,
};
try {
const key = getSessionStorageKey(canvasId);
const saved = localStorage.getItem(key);
const parsed = saved ? (JSON.parse(saved) as CanvasChatSession[]) : [];
const sanitized = parsed
.filter((session) => session?.id)
.map((session) => ({
id: session.id,
title: (session.title || "").trim() || "新对话",
createdAt: Number(session.createdAt) || now,
updatedAt: Number(session.updatedAt) || now,
}));
const hasCurrent = sanitized.some((session) => session.id === chatId);
const nextSessions = hasCurrent
? sanitized
: [fallbackSession, ...sanitized];
const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt);
setChatSessions(sorted);
setActiveChatId(chatId);
localStorage.setItem(key, JSON.stringify(sorted));
} catch (error) {
console.error("[CanvasChat] Failed to load chat sessions:", error);
setChatSessions([fallbackSession]);
setActiveChatId(chatId);
}
}, [canvasId, chatId]);
useEffect(() => {
if (!isSessionMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!sessionMenuRef.current?.contains(event.target as Node)) {
setIsSessionMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isSessionMenuOpen]);
useEffect(() => {
try {
localStorage.setItem(
getSessionStorageKey(canvasId),
JSON.stringify(chatSessions)
);
} catch (error) {
console.error("[CanvasChat] Failed to save chat sessions:", error);
}
}, [canvasId, chatSessions]);
// Canvas store
const addNode = useCanvasStore((state) => state.addNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const nodes = useCanvasStore((state) => state.nodes);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const pendingChatAttachments = useCanvasStore((state) => state.pendingChatAttachments);
const clearPendingChatAttachments = useCanvasStore((state) => state.clearPendingChatAttachments);
// 🆕 物品拾取
const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects);
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
const rawChatId = toRawCanvasChatId(activeChatId);
// 从 API 加载历史消息
const { data: historyData } = useSWR<{
messages: ChatMessage[];
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
// 获取选中的图片节点
const selectedImageNodes = nodes.filter(
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
);
// 🆕 辅助函数:在 contenteditable 中插入图片标签
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
// 检查 DOM 中是否已存在该 URL 的标签(如果存在则不重复添加)
const existing = editableDiv.querySelector(`[data-attachment-url="${url}"]`);
if (existing) {
console.log('[CanvasChat] 标签已存在于 DOM 中,跳过:', url.substring(0, 50));
return;
}
const tagSpan = document.createElement('span');
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
tagSpan.innerHTML = `
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
<span class="text-xs text-foreground/80">${shortName}</span>
`;
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
const spaceNode = document.createTextNode("\u200b");
range.insertNode(spaceNode);
range.insertNode(tagSpan);
range.setStartAfter(spaceNode);
range.setEndAfter(spaceNode);
selection.removeAllRanges();
selection.addRange(range);
} else {
editableDiv.appendChild(tagSpan);
editableDiv.appendChild(document.createTextNode("\u200b"));
}
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
}, []);
// 🆕 辅助函数:在 contenteditable 中插入物品标签(带图片缩略图,样式与普通图片标签一致)
const insertPickedObjectTagInEditor = useCallback((
url: string,
objectName: string,
objectPosition: string,
index: number
) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
// 检查 DOM 中是否已存在该物品的标签
const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]');
for (const tag of existingTags) {
if (tag.getAttribute('data-attachment-url') === url &&
tag.getAttribute('data-object-name') === objectName) {
console.log('[CanvasChat] 物品标签已存在,跳过');
return;
}
}
const tagSpan = document.createElement('span');
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.setAttribute('data-picked-object', 'true');
tagSpan.setAttribute('data-object-name', objectName);
tagSpan.setAttribute('data-object-position', objectPosition);
// 样式与普通图片标签一致
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
// 使用图片缩略图,不使用 emoji 或图标
tagSpan.innerHTML = `
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
<span class="text-xs text-foreground/80">${objectName}</span>
<span class="text-xs text-foreground/50">·</span>
<span class="text-xs text-foreground/70">${objectPosition}</span>
`;
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
const spaceNode = document.createTextNode("\u200b");
range.insertNode(spaceNode);
range.insertNode(tagSpan);
range.setStartAfter(spaceNode);
range.setEndAfter(spaceNode);
selection.removeAllRanges();
selection.addRange(range);
} else {
editableDiv.appendChild(tagSpan);
editableDiv.appendChild(document.createTextNode("\u200b"));
}
console.log('[CanvasChat] 已插入物品标签:', objectName, objectPosition);
}, []);
// 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签
useEffect(() => {
if (pendingChatAttachments.length > 0) {
// 获取当前附件列表
const currentAttachments = [...attachments];
const existingUrls = new Set(currentAttachments.map(a => a.url));
// 过滤出需要添加的图片(不在当前附件列表中的)
const toAdd = pendingChatAttachments.filter(a => !existingUrls.has(a.url));
if (toAdd.length > 0) {
// 插入图片标签到编辑器
toAdd.forEach((att, i) => {
insertImageTagInEditor(att.url, att.name, currentAttachments.length + i);
});
// 更新附件列表
setAttachments([...currentAttachments, ...toAdd]);
// 聚焦到输入框
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
}
// 清空待添加列表
clearPendingChatAttachments();
}
}, [pendingChatAttachments, attachments, insertImageTagInEditor, clearPendingChatAttachments]);
// 🆕 监听 pendingPickedObjects,当用户确认拾取物品时添加标签
useEffect(() => {
if (pendingPickedObjects.length > 0) {
const currentAttachments = [...attachments];
pendingPickedObjects.forEach((picked, i) => {
// 检查是否已存在相同的物品
const exists = currentAttachments.some(a =>
a.url === picked.imageUrl &&
a.pickedObject?.objectName === picked.objectName
);
if (!exists) {
// 插入物品标签到编辑器
insertPickedObjectTagInEditor(
picked.imageUrl,
picked.objectName,
picked.objectPosition,
currentAttachments.length + i
);
// 添加到附件列表(带物品信息,原图片 URL 会发送给 AI 用于多图融合编辑)
currentAttachments.push({
url: picked.imageUrl, // 原图片 URL
name: `${picked.objectName}-${picked.objectPosition}`, // 物品名称-位置
contentType: 'image/jpeg',
pickedObject: {
objectName: picked.objectName,
objectPosition: picked.objectPosition,
clickX: picked.clickX,
clickY: picked.clickY,
boundingBox: picked.boundingBox,
},
});
}
});
setAttachments(currentAttachments);
// 聚焦到输入框
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
// 清空待添加列表
clearPendingPickedObjects();
}
}, [pendingPickedObjects, attachments, insertPickedObjectTagInEditor, clearPendingPickedObjects]);
// 🆕 标签悬停预览 - 使用全局事件监听
useEffect(() => {
let lastTagElement: HTMLElement | null = null;
const handleMouseMove = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const tagSpan = target.closest('[data-attachment-url]') as HTMLElement | null;
// 检查是否在预览弹窗内
const inPreview = target.closest('[data-tag-preview]');
if (inPreview) {
// 在预览弹窗内,保持显示
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
return;
}
if (tagSpan && tagSpan !== lastTagElement) {
lastTagElement = tagSpan;
// 清除之前的延迟隐藏
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
const url = tagSpan.getAttribute('data-attachment-url');
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
const objectName = tagSpan.getAttribute('data-object-name');
if (url) {
const rect = tagSpan.getBoundingClientRect();
// 从 attachments 中获取物品信息
const attachment = attachments.find(a =>
a.url === url &&
(isPickedObject ? a.pickedObject?.objectName === objectName : true)
);
// 获取聊天面板容器边界(向上查找最近的面板容器)
const panelContainer = tagSpan.closest('.flex.flex-col.h-full') as HTMLElement | null;
let containerBounds: TagPreviewInfo['containerBounds'] = undefined;
if (panelContainer) {
const panelRect = panelContainer.getBoundingClientRect();
containerBounds = {
left: panelRect.left,
right: panelRect.right,
top: panelRect.top,
bottom: panelRect.bottom,
};
}
setTagPreview({
url,
name: objectName || tagSpan.textContent || '图片',
x: rect.left + rect.width / 2,
y: rect.top,
containerBounds,
pickedObject: attachment?.pickedObject ? {
objectName: attachment.pickedObject.objectName,
objectPosition: attachment.pickedObject.objectPosition,
clickX: attachment.pickedObject.clickX,
clickY: attachment.pickedObject.clickY,
} : undefined,
});
}
} else if (!tagSpan && lastTagElement) {
// 离开标签
lastTagElement = null;
previewTimeoutRef.current = setTimeout(() => {
setTagPreview(null);
}, 200);
}
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
};
}, [attachments]);
// 获取模型列表
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;
canvasEnabled?: boolean;
canvasDefaultModelId?: string | null;
}>("/api/site-settings", fetcher);
// 过滤支持图片生成的模型
const imageModels = modelsData?.models?.filter(m => m.supportsImageGen) || [];
// 智能自动模型选项
const SMART_AUTO_MODEL = {
id: "auto",
modelId: "auto",
displayName: "智能自动",
providerType: "auto",
supportsImageGen: true,
};
// 所有可选模型(智能自动 + 生图模型)
const allModels = [SMART_AUTO_MODEL, ...imageModels];
// 默认选择模型(根据系统设置)
useEffect(() => {
if (!selectedModelId && siteSettings) {
// 使用系统设置的默认模型,如果没有设置则使用智能自动
const defaultModel = siteSettings.canvasDefaultModelId || "auto";
setSelectedModelId(defaultModel);
}
}, [selectedModelId, siteSettings]);
const currentModelIdRef = useRef(selectedModelId);
const imageParamsRef = useRef(imageParams);
useEffect(() => {
currentModelIdRef.current = selectedModelId;
}, [selectedModelId]);
useEffect(() => {
imageParamsRef.current = imageParams;
}, [imageParams]);
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
const {
messages,
setMessages,
sendMessage,
status,
stop,
} = useChat<ChatMessage>({
id: activeChatId,
messages: mergedInitialMessages,
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),
});
const hiddenPrompt = hiddenPromptRef.current || undefined;
const autoContext = autoContextRef.current || undefined;
hiddenPromptRef.current = null;
autoContextRef.current = null;
return {
body: {
id: rawId,
message: request.messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: "private",
videoParams: undefined, // 必须传递,即使是 undefined
imageParams: imageParamsRef.current || undefined, // 传递图片参数
hiddenPrompt,
autoContext,
...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 hasLoadedHistory = useRef(false);
useEffect(() => {
if (historyData && !hasLoadedHistory.current) {
hasLoadedHistory.current = true;
setMessages(historyData.messages || []);
console.log('[CanvasChat] 已加载历史消息:', historyData.messages?.length || 0);
}
}, [historyData, setMessages]);
// 监听消息变化,提取图片并添加到画布
// 只处理新生成的图片,不处理历史消息中的图片
const processedMessageIds = useRef<Set<string>>(new Set());
const isInitialLoad = useRef(true);
useEffect(() => {
hasLoadedHistory.current = false;
processedMessageIds.current.clear();
isInitialLoad.current = true;
hiddenPromptRef.current = null;
autoContextRef.current = null;
setMessages([]);
setInput("");
setAttachments([]);
setIsSessionMenuOpen(false);
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [activeChatId, setMessages]);
useEffect(() => {
if (!activeChatId) return;
const derivedTitle = deriveSessionTitleFromMessages(messages);
const now = Date.now();
setChatSessions((prevSessions) => {
const currentIndex = prevSessions.findIndex(
(session) => session.id === activeChatId
);
const fallbackTitle = `对话 ${prevSessions.length + 1}`;
if (currentIndex < 0) {
const created: CanvasChatSession = {
id: activeChatId,
title: derivedTitle || fallbackTitle,
createdAt: now,
updatedAt: now,
};
return [created, ...prevSessions];
}
const current = prevSessions[currentIndex];
const nextTitle = derivedTitle || current.title || fallbackTitle;
const shouldUpdate =
current.title !== nextTitle || current.updatedAt !== now;
if (!shouldUpdate) return prevSessions;
const nextSessions = [...prevSessions];
nextSessions[currentIndex] = {
...current,
title: nextTitle,
updatedAt: now,
};
nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
return nextSessions;
});
}, [activeChatId, messages]);
useEffect(() => {
// 首次加载历史消息时,只记录已处理的消息 ID,不添加图片到画布
// (画布数据应该从 canvasData 中加载,而不是从消息中提取)
if (isInitialLoad.current) {
messages.forEach(message => {
if (message.role === 'assistant') {
processedMessageIds.current.add(message.id);
}
});
isInitialLoad.current = false;
return;
}
// 只处理新消息
messages.forEach(async (message, messageIndex) => {
if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) {
const content = message.parts?.map((p: any) => p.text || '').join('') || '';
const imageUrls = extractImageUrls(content);
const generationPrompt = findNearestUserPrompt(messages, messageIndex);
if (imageUrls.length > 0) {
processedMessageIds.current.add(message.id);
// 检查画布中是否已存在相同 URL 的图片
const existingUrls = new Set(
nodes.filter(n => n.type === 'image' && (n.data as any)?.src)
.map(n => (n.data as any).src)
);
// 只添加不存在的图片
const newUrls = imageUrls.filter(url => !existingUrls.has(url));
// 异步获取每张图片的尺寸并添加到画布
for (let index = 0; index < newUrls.length; index++) {
const url = newUrls[index];
// 获取图片原始尺寸
const dimensions = await getImageDimensions(url);
// 计算位置(避免重叠,根据图片宽度动态调整间距)
const existingNodes = nodes.length + index;
const spacing = 20; // 图片间距
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
addNode({
type: 'image',
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: 'completed',
prompt: generationPrompt || '对话生成',
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
},
},
connections: [],
});
}
if (newUrls.length > 0) {
toast.success(`已将 ${newUrls.length} 张图片添加到画布`);
}
}
}
});
}, [messages, addNode, nodes]);
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
useEffect(() => {
if (!messages.length || !nodes.length) return;
const promptByImageUrl = new Map<string, string>();
messages.forEach((message, messageIndex) => {
if (message.role !== "assistant") return;
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
const imageUrls = extractImageUrls(content);
if (!imageUrls.length) return;
const prompt = findNearestUserPrompt(messages, messageIndex);
if (!prompt) return;
imageUrls.forEach((url) => {
if (!promptByImageUrl.has(url)) {
promptByImageUrl.set(url, prompt);
}
});
});
nodes.forEach((node) => {
if (node.type !== "image") return;
const data = (node.data as any) || {};
const src = data.src as string | undefined;
if (!src) return;
if (!isGenericCanvasPrompt(data.prompt)) return;
const prompt = promptByImageUrl.get(src);
if (!prompt) return;
updateNode(node.id, {
data: {
...data,
prompt,
metadata: {
...(data.metadata || {}),
generationPrompt: prompt,
},
},
});
});
}, [messages, nodes, updateNode]);
// 滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false);
// 获取最近的图片附件(支持多张图片与名称同步)
const getRecentImageAttachments = useCallback((): CanvasImageAttachment[] => {
const result: CanvasImageAttachment[] = [];
const addedUrls = new Set<string>();
const addAttachment = (item: CanvasImageAttachment) => {
if (!item.url || addedUrls.has(item.url)) return;
addedUrls.add(item.url);
result.push(item);
};
const getNodeName = (node: any, index: number) => {
return (
node?.data?.title ||
node?.data?.prompt ||
`图片 ${index + 1}`
);
};
// 1. 优先使用画布中选中的图片(支持多选)
if (selectedImageNodes.length > 0) {
selectedImageNodes.forEach((node, index) => {
const src = (node.data as any)?.src;
if (src) {
addAttachment({
url: src,
name: getNodeName(node, index),
contentType: "image/jpeg",
});
}
});
if (result.length > 0) {
console.log("[CanvasChat] 使用选中的图片:", result.length, "张");
return result;
}
}
// 2. 历史对话最近一轮图片
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
const msgAttachments: CanvasImageAttachment[] = [];
if (msg.role === "user" && (msg as any).parts) {
for (const part of (msg as any).parts) {
if (part.type === "file" && part.url && part.mediaType?.startsWith("image/")) {
msgAttachments.push({
url: part.url,
name: part.name || "图片",
contentType: part.mediaType || "image/jpeg",
});
}
}
}
if (msgAttachments.length > 0) {
msgAttachments.forEach(addAttachment);
console.log("[CanvasChat] 使用历史对话图片:", result.length, "张");
return result;
}
}
// 3. 画布中最近图片
const imageNodes = nodes.filter((n) => n.type === "image" && (n.data as any)?.src);
if (imageNodes.length > 0) {
imageNodes.slice(-5).forEach((node, index) => {
addAttachment({
url: (node.data as any).src,
name: getNodeName(node, index),
contentType: "image/jpeg",
});
});
console.log("[CanvasChat] 使用画布图片:", result.length, "张");
}
return result;
}, [messages, nodes, selectedImageNodes]);
// 🆕 智能自动模式上下文(画布图片布局)
const buildAutoContext = useCallback(() => {
const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src);
if (imageNodes.length === 0) return "";
const MAX_CONTEXT_IMAGES = 6;
const xs = imageNodes.map(n => n.position.x);
const ys = imageNodes.map(n => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const positionLabel = (node: (typeof imageNodes)[number]) => {
const relX = (node.position.x - minX) / spanX;
const relY = (node.position.y - minY) / spanY;
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
if (horizontal === "中" && vertical === "中") return "中间";
return `${vertical}${horizontal}`;
};
const selectedNodes = selectedImageNodes.length > 0 ? selectedImageNodes : [];
const contextNodes = (selectedNodes.length > 0 ? selectedNodes : imageNodes.slice(-MAX_CONTEXT_IMAGES)).slice(0, MAX_CONTEXT_IMAGES);
const lines = [
`画布图片总数: ${imageNodes.length}`,
selectedNodes.length > 0
? `用户已选中 ${selectedNodes.length} 张图片,优先处理这些`
: `用户未选中图片,默认使用最近 ${contextNodes.length} 张`,
...contextNodes.map((node, index) => {
const label = positionLabel(node);
const metadata = (node.data as any)?.metadata;
const originalWidth = Number(metadata?.originalWidth || 0);
const originalHeight = Number(metadata?.originalHeight || 0);
const originalSize =
originalWidth > 0 && originalHeight > 0
? `,原图分辨率 ${Math.round(originalWidth)}x${Math.round(originalHeight)}`
: "";
const prompt = (node.data as any)?.prompt;
return `图片${index + 1}: ${label}${originalSize}${prompt ? `,描述:${String(prompt).slice(0, 60)}` : ""}`;
}),
"若用户提到左/右/上/下/中等方位,请匹配对应位置的图片。",
];
return lines.join("\n");
}, [nodes, selectedImageNodes]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
if (!input.trim() && attachments.length === 0) return;
if (!selectedModelId) {
toast.error("请选择一个模型");
return;
}
// 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致)
const parts: any[] = [];
// 🆕 如果没有附件,自动使用最近的图片(支持多张)
let effectiveAttachments = [...attachments];
if (effectiveAttachments.length === 0 && input.trim()) {
const recentAttachments = getRecentImageAttachments();
if (recentAttachments.length > 0) {
// 自动添加所有最近的图片作为附件
recentAttachments.forEach((attachment) => {
effectiveAttachments.push({
url: attachment.url,
name: attachment.name,
contentType: attachment.contentType,
});
});
console.log('[CanvasChat] 自动使用最近图片:', recentAttachments.length, '张');
}
}
// 🆕 检查是否有标注的图片
const annotatedNodes = selectedImageNodes.filter(node => (node.data as any)?.hasAnnotations);
const hasAnnotatedImages = annotatedNodes.length > 0;
// 添加文件部分(作为 file 类型的 part)
// 🆕 收集物品信息用于构建隐藏提示
const pickedObjectInfos: string[] = [];
effectiveAttachments.forEach((a) => {
const att = a as PickedObjectAttachment;
parts.push({
type: "file" as const,
mediaType: a.contentType,
name: a.name,
url: a.url,
hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注
pickedObject: att.pickedObject, // 🆕 包含物品信息
});
// 收集物品信息
if (att.pickedObject) {
const { objectName, objectPosition, boundingBox } = att.pickedObject;
pickedObjectInfos.push(
`【物品:${objectName}】位置:${objectPosition},边界框:(${Math.round(boundingBox.x * 100)}%, ${Math.round(boundingBox.y * 100)}%) - (${Math.round((boundingBox.x + boundingBox.width) * 100)}%, ${Math.round((boundingBox.y + boundingBox.height) * 100)}%)`
);
}
});
// 添加文本部分(用户可见)
let textContent = input.trim() || (effectiveAttachments.length > 0 ? "请处理这张图片" : "");
// 🆕 隐藏提示词(不在 UI 中展示)
const hiddenSegments: string[] = [];
if (pickedObjectInfos.length > 0) {
const objectsPrompt = pickedObjectInfos.join('\n');
hiddenSegments.push(
`【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`
);
console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个');
}
if (hasAnnotatedImages) {
hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】");
console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示');
}
const hiddenPrompt = hiddenSegments.join('\n\n').trim();
hiddenPromptRef.current = hiddenPrompt || null;
const autoContext = buildAutoContext();
autoContextRef.current = autoContext ? autoContext : null;
if (textContent) {
parts.push({ type: "text" as const, text: textContent });
}
// 使用与 multimodal-input 相同的格式发送消息
sendMessage({
role: "user",
parts: parts,
});
// 清空状态
setInput("");
setAttachments([]);
// 🆕 清空画布选中状态(防止 useEffect 重新添加标签)
clearSelection();
// 🆕 清空 contenteditable div 的内容
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = '';
console.log('[CanvasChat] 已清空输入框和选中状态');
}
}, [input, attachments, selectedModelId, sendMessage, getRecentImageAttachments, clearSelection, buildAutoContext]);
// 处理文件选择 - 上传后立即添加到画布
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
let firstImageUrl: string | null = null;
// 记录本次上传开始时的节点数量,用于计算新图片位置
const baseNodeCount = nodes.length;
let uploadIndex = 0;
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
continue;
}
if (file.size > 50 * 1024 * 1024) {
toast.error('图片大小不能超过 50MB');
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 => {
const newIndex = prev.length;
// 在编辑器中插入图片标签
setTimeout(() => {
insertImageTagInEditor(imageUrl, file.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [...prev, {
url: imageUrl,
name: file.name,
contentType: file.type,
}];
});
// 获取图片原始尺寸后添加到画布
const dimensions = await getImageDimensions(imageUrl);
// 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠
const nodeIndex = baseNodeCount + uploadIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
uploadIndex++; // 递增索引
addNode({
type: 'image',
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
status: 'completed',
title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
toast.success('图片已添加到画布');
} catch (error) {
toast.error('图片上传失败');
}
}
// 如果是第一次上传图片,自动识别并设置画布标题和缩略图
console.log('[CanvasChat] 检查是否需要自动命名:', {
firstImageUrl: !!firstImageUrl,
hasSetCanvasTitle: hasSetCanvasTitle.current
});
if (firstImageUrl && !hasSetCanvasTitle.current) {
hasSetCanvasTitle.current = true;
console.log('[CanvasChat] 开始调用 auto-title API...');
// 异步调用 AI 识别图片主体并更新画布
(async () => {
try {
// 调用 auto-title API 识别图片主体
console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl);
const response = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: firstImageUrl }),
});
console.log('[CanvasChat] auto-title 响应状态:', response.status);
if (response.ok) {
const data = await response.json();
const title = data.title || '未命名画布';
console.log('[CanvasChat] 识别到的标题:', title);
// 提取纯 UUID(去掉 canvas- 前缀)
const rawId = chatId.startsWith('canvas-')
? chatId.replace('canvas-', '')
: chatId;
console.log('[CanvasChat] 更新画布:', rawId);
// 更新画布标题和缩略图
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
thumbnail: firstImageUrl,
}),
});
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
if (updateResponse.ok) {
console.log('[CanvasChat] 画布已自动命名为:', title);
toast.success(`画布已命名为: ${title}`);
} else {
console.error('[CanvasChat] 画布更新失败');
}
} else {
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
}
} catch (error) {
console.error('[CanvasChat] 自动命名失败:', error);
}
})();
}
e.target.value = '';
};
// 移除附件(通过标签元素删除,支持同图多物品)
const removeAttachmentByTagElement = (tagEl: HTMLElement) => {
const url = tagEl.getAttribute('data-attachment-url') || '';
const isPicked = tagEl.getAttribute('data-picked-object') === 'true';
const objectName = tagEl.getAttribute('data-object-name') || '';
const objectPosition = tagEl.getAttribute('data-object-position') || '';
setAttachments(prev => prev.filter(a => {
if (!url) return true;
if (isPicked) {
return !(
a.url === url &&
a.pickedObject?.objectName === objectName &&
a.pickedObject?.objectPosition === objectPosition
);
}
return a.url !== url;
}));
if (url) {
console.log('[CanvasChat] 已移除附件:', url.substring(0, 50));
}
};
// 从编辑器 DOM 同步附件列表,避免删除标签后仍发送
const syncAttachmentsWithEditor = useCallback((editableDiv?: HTMLDivElement | null) => {
const container = editableDiv || (textareaRef.current as HTMLDivElement | null);
if (!container) return;
const tagElements = Array.from(container.querySelectorAll('[data-attachment-url]')) as HTMLElement[];
const tagKeys = new Set<string>();
tagElements.forEach((el) => {
const url = el.getAttribute('data-attachment-url') || '';
if (!url) return;
const isPicked = el.getAttribute('data-picked-object') === 'true';
if (isPicked) {
const objectName = el.getAttribute('data-object-name') || '';
const objectPosition = el.getAttribute('data-object-position') || '';
tagKeys.add(`picked|${url}|${objectName}|${objectPosition}`);
} else {
tagKeys.add(`image|${url}`);
}
});
setAttachments(prev => prev.filter((att) => {
if (att.pickedObject) {
const key = `picked|${att.url}|${att.pickedObject.objectName}|${att.pickedObject.objectPosition}`;
return tagKeys.has(key);
}
return tagKeys.has(`image|${att.url}`);
}));
}, []);
// 移除附件(通过索引删除,保留兼容性)
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
const isLoading = status === 'streaming' || status === 'submitted';
const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user");
const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages;
const hasGenerationStart = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_START -->")
)
);
const hasGenerationEnd = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
)
);
const currentModel = allModels.find(m => m.id === selectedModelId);
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
const loadingImageCount = imageParams.imageCount || 1;
const createNewChatSession = useCallback(() => {
const now = Date.now();
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
const fallbackTitle = `对话 ${chatSessions.length + 1}`;
const newSession: CanvasChatSession = {
id: newChatSessionId,
title: fallbackTitle,
createdAt: now,
updatedAt: now,
};
setChatSessions((prevSessions) => [newSession, ...prevSessions]);
setActiveChatId(newChatSessionId);
toast.success("已新建聊天窗口");
}, [chatSessions.length]);
const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) {
setIsSessionMenuOpen(false);
return;
}
setActiveChatId(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId]);
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"
onClick={createNewChatSession}
title="新建聊天窗口"
>
<Plus className="h-4 w-4" />
</Button>
<div className="relative" ref={sessionMenuRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full hover:bg-muted",
isSessionMenuOpen && "bg-muted"
)}
onClick={() => setIsSessionMenuOpen((open) => !open)}
title="聊天窗口记录"
>
<History className="h-4 w-4" />
</Button>
{isSessionMenuOpen && (
<div className="absolute right-0 top-10 w-64 rounded-xl border bg-background/95 backdrop-blur-md shadow-xl p-2">
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{chatSessions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
) : (
chatSessions.map((session, index) => {
const isActive = session.id === activeChatId;
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={session.id}
type="button"
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
onClick={() => switchToSession(session.id)}
>
<div className="text-sm font-medium truncate">
{session.title || `对话 ${index + 1}`}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
最近更新 {timeLabel}
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
<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) => {
// 🆕 清理消息中的内部标记并检查是否有实际内容
const rawParts = message.parts || [];
const cleanedParts = rawParts.map((part: any) => {
if (part.type === 'text' && part.text) {
return {
...part,
text: part.text
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.trim()
};
}
return part;
});
// 检查清理后是否有实际内容(更严格的检查)
let hasContent = false;
for (const part of cleanedParts) {
if (part.type === 'text' && part.text && part.text.trim().length > 0) {
hasContent = true;
break;
}
if ((part.type === 'file' || part.type === 'image') && part.url) {
hasContent = true;
break;
}
}
// 同时检查附件
const attachmentsList = (message as any).attachments || [];
if (attachmentsList.length > 0) {
hasContent = true;
}
// 没有内容则不渲染(包括空 parts 的情况)
if (!hasContent) {
console.log('[CanvasChat] 跳过空消息:', message.id);
return null;
}
// 使用清理后的消息
const cleanedMessage = { ...message, parts: cleanedParts };
const assistantTextParts = message.role === "assistant"
? cleanedParts.filter((part: any) => part.type === "text" && part.text)
: [];
const hasAssistantImages = assistantTextParts.some((part: any) =>
extractImageUrls(part.text || "").length > 0
);
const hasAssistantText = assistantTextParts.some((part: any) => {
const textWithoutImages = (part.text || "")
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
.trim();
return textWithoutImages.length > 0;
});
const isImageOnlyAssistant = message.role === "assistant" && hasAssistantImages && !hasAssistantText;
return (
<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"
)}>
{message.role === 'user' ? (
// 🆕 用户消息:使用图片标签样式(类似竞品,按 parts 原始顺序渲染)
<div className="bg-card border rounded-2xl rounded-tr-sm px-3 py-2 shadow-sm">
<div className="flex flex-wrap items-center gap-1.5">
{/* 按 parts 原始顺序渲染图片标签和文字 */}
{cleanedMessage.parts?.map((part: any, idx: number) => {
if (part.type === 'file' && part.url) {
// 图片标签
return (
<span
key={`part-${idx}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded-lg text-xs"
>
<img
src={part.url}
alt=""
className="w-5 h-5 object-cover rounded"
/>
<span className="max-w-[80px] truncate text-foreground">{part.name || 'Image'}</span>
</span>
);
}
if (part.type === 'text' && part.text) {
// 文字(去掉标注提示前缀)
let text = part.text.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '').trim();
if (!text) return null;
return (
<span key={`part-${idx}`} className="text-sm text-foreground">{text}</span>
);
}
return null;
})}
{/* 如果有 attachments 但没有 file parts,也显示 */}
{(!cleanedMessage.parts?.some((p: any) => p.type === 'file') && (message as any).attachments?.length > 0) &&
(message as any).attachments.map((att: any, idx: number) => (
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded-lg text-xs"
>
<img
src={att.url}
alt=""
className="w-5 h-5 object-cover rounded"
/>
<span className="max-w-[80px] truncate text-foreground">{att.name || 'Image'}</span>
</span>
))
}
</div>
</div>
) : (
// AI 消息:保持原样式
<div
className={cn(
"text-card-foreground text-sm",
isImageOnlyAssistant
? "bg-transparent border-0 rounded-none px-0 py-0 shadow-none"
: "bg-card border rounded-2xl rounded-tl-sm px-4 py-2.5 shadow-sm"
)}
>
{/* 消息内容 */}
<div className="whitespace-pre-wrap break-words leading-relaxed">
{cleanedMessage.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={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
{imageUrls.map((url, idx) => (
<div
key={idx}
className={cn(
"relative group overflow-hidden border transition-all",
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
)}
>
<img
src={url}
alt=""
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
style={{ maxHeight: "260px" }}
onClick={() => setFullscreenPreview({ url })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
}}
/>
</div>
))}
</div>
)}
</div>
);
}
return null;
})}
</div>
</div>
)}
</div>
</div>
);
})}
{shouldShowImageLoading && (
<ImageGenerationLoading
imageCount={loadingImageCount}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
/>
)}
{isLoading && !shouldShowImageLoading && (
<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>
{fullscreenPreview && createPortal(
<div
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
onClick={() => setFullscreenPreview(null)}
>
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览"
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img
src={fullscreenPreview.url}
alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
点击空白处或按 Esc 关闭
</div>
</div>,
document.body
)}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-background/80 backdrop-blur-md border-t">
<div className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background">
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div className="relative px-4 pt-3 pb-12 min-h-[60px]">
<div
ref={textareaRef as any}
contentEditable={!isLoading}
suppressContentEditableWarning
className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/50"
data-placeholder="请输入你的设计需求,例如:绿色椅子 替换 Image"
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除图片标签的 data-url)
const target = e.target as HTMLDivElement;
// 获取所有文本节点的内容
let textContent = '';
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, null);
let node;
while (node = walker.nextNode()) {
textContent += node.textContent;
}
setInput(textContent.trim());
syncAttachmentsWithEditor(target);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace') {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed && range.startOffset === 0) {
// 检查前一个兄弟节点是否是图片标签
const prevSibling = range.startContainer.previousSibling as HTMLElement;
if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) {
e.preventDefault();
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
// 使用 URL 来删除附件,更可靠
removeAttachmentByTagElement(prevSibling);
prevSibling.remove();
}
}
}
}
}}
onPaste={(e) => {
// 只粘贴纯文本
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div>
{/* Bottom Toolbar */}
<div className="absolute left-2 bottom-2 right-2 flex items-center gap-2">
{/* 左侧:附件和模型选择 */}
<div className="flex items-center gap-2 min-w-0 flex-shrink">
<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 flex-shrink-0"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
<Paperclip className="h-4 w-4" />
</Button>
{/* Agent Selector (Model) - 包含智能自动选项 */}
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className={cn(
"h-8 text-xs transition-colors rounded-full px-3 gap-1 w-auto flex-shrink-0",
selectedModelId === "auto"
? "bg-gradient-to-r from-purple-50 to-blue-50 text-purple-600 border-purple-100 hover:from-purple-100 hover:to-blue-100"
: "bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100 hover:border-blue-200"
)}>
{selectedModelId === "auto" ? (
<Zap className="h-3 w-3" />
) : (
<Sparkles className="h-3 w-3" />
)}
<span className="truncate max-w-[60px]">
{allModels.find(m => m.id === selectedModelId)?.displayName || "智能自动"}
</span>
</SelectTrigger>
<SelectContent>
{/* 智能自动选项 */}
<SelectItem value="auto" className="text-xs">
<div className="flex items-center gap-2">
<Zap className="h-3 w-3 text-purple-500" />
<span>智能自动</span>
<span className="text-[10px] text-muted-foreground">先对话再生图</span>
</div>
</SelectItem>
{/* 分隔线 */}
{imageModels.length > 0 && (
<div className="my-1 border-t border-border" />
)}
{/* 生图模型列表 */}
{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-1 min-w-0 overflow-hidden">
<ImageParamsBar
modelName={allModels.find(m => m.id === selectedModelId)?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={allModels.find(m => m.id === selectedModelId)?.providerType}
/>
</div>
{/* 右侧:发送按钮(固定不收缩) */}
<Button
size="icon"
className={cn(
"h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
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>
{/* 🆕 标签悬停预览弹窗 - 固定显示在标签上方 */}
{tagPreview && (() => {
// 计算弹窗位置
const previewWidth = 240;
const previewHeight = 280;
const padding = 16;
// 简化:直接使用标签位置,在标签上方居中显示
// tagPreview.x 是标签中心的x坐标
// tagPreview.y 是标签顶部的y坐标
let left = tagPreview.x - previewWidth / 2;
let top = tagPreview.y - previewHeight - 12;
// 确保不超出视口左边界
if (left < padding) {
left = padding;
}
// 确保不超出视口右边界
if (typeof window !== 'undefined' && left + previewWidth > window.innerWidth - padding) {
left = window.innerWidth - previewWidth - padding;
}
// 如果上方空间不够,显示在标签下方
if (top < padding) {
top = tagPreview.y + 36; // 标签下方
}
// 确保不超出视口底部
if (typeof window !== 'undefined' && top + previewHeight > window.innerHeight - padding) {
top = window.innerHeight - previewHeight - padding;
}
// 最终确保 top 不为负
if (top < padding) {
top = padding;
}
// 使用 Portal 渲染到 body,避免 backdrop-blur 影响 fixed 定位
return createPortal(
<div
data-tag-preview="true"
className="fixed z-[10001] bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-border/50 overflow-hidden animate-in fade-in zoom-in-95 duration-150"
style={{
left: left,
top: top,
width: previewWidth,
}}
onMouseEnter={() => {
// 鼠标进入预览弹窗,取消隐藏
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
}}
onMouseLeave={() => {
// 鼠标离开预览弹窗,隐藏
setTagPreview(null);
}}
>
{/* 图片全貌 - 使用 inline-block + 居中,让标注点相对于图片本身定位 */}
<div className="flex justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="relative inline-block">
<img
src={tagPreview.url}
alt={tagPreview.name}
className="block max-w-full max-h-[180px] h-auto"
/>
{/* 如果是物品标签,显示标注位置 */}
{tagPreview.pickedObject && (
<>
{/* 标注点 */}
<div
className="absolute pointer-events-none"
style={{
left: `${tagPreview.pickedObject.clickX * 100}%`,
top: `${tagPreview.pickedObject.clickY * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 序号标记 */}
<div className="relative flex items-center justify-center">
{/* 脉冲动画背景 */}
<div className="absolute w-6 h-6 rounded-full bg-blue-500/30 animate-ping" />
{/* 主标记点 */}
<div className="relative w-5 h-5 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M12 2v20M2 12h20" />
</svg>
</div>
</div>
</div>
{/* 物品名称标签 */}
<div
className="absolute bg-blue-500 text-white text-xs px-1.5 py-0.5 rounded shadow-lg whitespace-nowrap"
style={{
left: `${tagPreview.pickedObject.clickX * 100}%`,
top: `${tagPreview.pickedObject.clickY * 100}%`,
transform: 'translate(-50%, -140%)',
}}
>
{tagPreview.pickedObject.objectName}
</div>
</>
)}
</div>
</div>
{/* 底部信息 */}
<div className="px-3 py-2 bg-zinc-50 dark:bg-zinc-800/50 border-t border-border/30">
<div className="text-xs font-medium text-foreground/80 truncate">
{tagPreview.pickedObject ? tagPreview.pickedObject.objectName : tagPreview.name}
</div>
{tagPreview.pickedObject && (
<div className="text-xs text-muted-foreground">
{tagPreview.pickedObject.objectPosition}
</div>
)}
</div>
</div>,
document.body
);
})()}
</div>
);
}