2239 lines
85 KiB
TypeScript
2239 lines
85 KiB
TypeScript
"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 { extractImageUrlsFromText } from "@/lib/image-url";
|
||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
||
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[] {
|
||
return extractImageUrlsFromText(text);
|
||
}
|
||
|
||
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 pendingLaunchRef = useRef<CanvasLaunchPayload | 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);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
pendingLaunchRef.current = null;
|
||
|
||
if (typeof window === "undefined") return;
|
||
|
||
try {
|
||
const storageKey = getCanvasLaunchStorageKey(canvasId);
|
||
const raw = sessionStorage.getItem(storageKey);
|
||
if (!raw) return;
|
||
|
||
sessionStorage.removeItem(storageKey);
|
||
const parsed = JSON.parse(raw) as CanvasLaunchPayload;
|
||
const normalizedAttachments = Array.isArray(parsed.attachments)
|
||
? parsed.attachments
|
||
.filter((item) => item?.url)
|
||
.map((item) => ({
|
||
url: String(item.url),
|
||
name: String(item.name || "参考图"),
|
||
contentType: String(item.contentType || "image/jpeg"),
|
||
}))
|
||
: [];
|
||
const normalizedPrompt = String(parsed.prompt || "").trim();
|
||
|
||
if (!normalizedPrompt && normalizedAttachments.length === 0) {
|
||
return;
|
||
}
|
||
|
||
setAttachments(normalizedAttachments as PickedObjectAttachment[]);
|
||
setInput(normalizedPrompt);
|
||
|
||
if (parsed.selectedModelId) {
|
||
setSelectedModelId(String(parsed.selectedModelId));
|
||
}
|
||
|
||
if (parsed.imageParams && typeof parsed.imageParams === "object") {
|
||
setImageParams((prev) => ({
|
||
...prev,
|
||
...parsed.imageParams,
|
||
}));
|
||
}
|
||
|
||
setTimeout(() => {
|
||
normalizedAttachments.forEach((attachment, index) => {
|
||
insertImageTagInEditor(attachment.url, attachment.name, index);
|
||
});
|
||
}, 50);
|
||
|
||
if (parsed.autoSend) {
|
||
pendingLaunchRef.current = {
|
||
prompt: normalizedPrompt,
|
||
attachments: normalizedAttachments,
|
||
autoSend: true,
|
||
createdAt: parsed.createdAt,
|
||
selectedModelId: parsed.selectedModelId,
|
||
imageParams: parsed.imageParams,
|
||
};
|
||
}
|
||
|
||
normalizedAttachments.forEach(async (attachment, index) => {
|
||
const dimensions = await getImageDimensions(attachment.url);
|
||
addNode({
|
||
type: "image",
|
||
position: {
|
||
x: -260 + index * 80,
|
||
y: -120 + (index % 2) * 40,
|
||
},
|
||
size: {
|
||
width: dimensions.width,
|
||
height: dimensions.height,
|
||
},
|
||
data: {
|
||
src: attachment.url,
|
||
status: "completed",
|
||
title: attachment.name || `参考图 ${index + 1}`,
|
||
prompt: normalizedPrompt || "",
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to restore launch payload:", error);
|
||
}
|
||
}, [canvasId, addNode, insertImageTagInEditor]);
|
||
|
||
// 🆕 监听 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) || [];
|
||
|
||
// Agent模式(自动路由)模型选项
|
||
const SMART_AUTO_MODEL = {
|
||
id: "auto",
|
||
modelId: "auto",
|
||
displayName: "Agent模式",
|
||
providerType: "auto",
|
||
supportsImageGen: true,
|
||
};
|
||
|
||
// 所有可选模型(Agent模式 + 生图模型)
|
||
const allModels = [SMART_AUTO_MODEL, ...imageModels];
|
||
|
||
// 默认选择模型(根据系统设置)
|
||
useEffect(() => {
|
||
if (!selectedModelId && siteSettings) {
|
||
// 使用系统设置的默认模型,如果没有设置则使用Agent模式
|
||
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 || "生成失败");
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
const launchPayload = pendingLaunchRef.current;
|
||
if (!launchPayload?.autoSend) return;
|
||
if (!selectedModelId || status !== "ready") return;
|
||
|
||
const launchAttachments = launchPayload.attachments || [];
|
||
const textContent =
|
||
launchPayload.prompt?.trim() ||
|
||
(launchAttachments.length > 0 ? "请基于这些图片继续生成设计方案" : "");
|
||
|
||
if (!textContent && launchAttachments.length === 0) {
|
||
pendingLaunchRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const parts: any[] = launchAttachments.map((attachment) => ({
|
||
type: "file" as const,
|
||
mediaType: attachment.contentType || "image/jpeg",
|
||
name: attachment.name || "参考图",
|
||
url: attachment.url,
|
||
}));
|
||
|
||
if (textContent) {
|
||
parts.push({ type: "text" as const, text: textContent });
|
||
}
|
||
|
||
sendMessage({
|
||
role: "user",
|
||
parts,
|
||
});
|
||
|
||
pendingLaunchRef.current = null;
|
||
setInput("");
|
||
setAttachments([]);
|
||
clearSelection();
|
||
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.innerHTML = "";
|
||
}
|
||
}, [selectedModelId, status, sendMessage, clearSelection]);
|
||
|
||
// 当历史消息加载完成后,更新消息列表
|
||
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;
|
||
pendingLaunchRef.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]);
|
||
|
||
// 🆕 Agent模式上下文(画布图片布局)
|
||
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"
|
||
onClick={onClose}
|
||
disabled={!onClose}
|
||
title="收起聊天面板"
|
||
>
|
||
<Maximize2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</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"
|
||
data-canvas-chat-input-anchor="true"
|
||
>
|
||
{/* 混合输入区域:使用 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) - 包含Agent模式选项 */}
|
||
<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 || "Agent模式"}
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{/* Agent模式选项 */}
|
||
<SelectItem value="auto" className="text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<Zap className="h-3 w-3 text-purple-500" />
|
||
<span>Agent模式</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>
|
||
);
|
||
}
|