feat: improve canvas workflow and auto title
Hide internal prompts from users, auto-focus new images, avoid overlap, enable drag-and-drop upload, and fix canvas navigation reloads.
This commit is contained in:
parent
335dc925d5
commit
bb4f51d198
@ -104,6 +104,26 @@ function hasAnnotatedImage(messages: ChatMessage[]): boolean {
|
||||
*/
|
||||
const ANNOTATION_SYSTEM_PROMPT = "【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。";
|
||||
|
||||
function applyHiddenPromptToMessages(messages: ChatMessage[], hiddenPrompt?: string): ChatMessage[] {
|
||||
if (!hiddenPrompt || !hiddenPrompt.trim()) return messages;
|
||||
const cloned = messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => ({ ...part })),
|
||||
}));
|
||||
for (let i = cloned.length - 1; i >= 0; i--) {
|
||||
const msg = cloned[i];
|
||||
if (msg.role !== "user") continue;
|
||||
const textPart = msg.parts.find((part) => part.type === "text") as { type: "text"; text: string } | undefined;
|
||||
if (textPart) {
|
||||
textPart.text = `${hiddenPrompt}\n\n${textPart.text}`.trim();
|
||||
} else {
|
||||
msg.parts.unshift({ type: "text", text: hiddenPrompt });
|
||||
}
|
||||
break;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义消息转换函数
|
||||
* 注意:gemini-image 和 huanke-gemini 都需要 URL 格式(不是 Base64)
|
||||
@ -316,11 +336,13 @@ export async function POST(request: Request) {
|
||||
message,
|
||||
selectedChatModel,
|
||||
selectedVisibilityType,
|
||||
hiddenPrompt,
|
||||
}: {
|
||||
id: string;
|
||||
message: ChatMessage;
|
||||
selectedChatModel: ChatModel["id"];
|
||||
selectedVisibilityType: VisibilityType;
|
||||
hiddenPrompt?: string;
|
||||
} = requestBody;
|
||||
|
||||
let finalModelId = selectedChatModel;
|
||||
@ -599,6 +621,11 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const uiMessages = [...convertToUIMessages(messagesFromDb), message];
|
||||
const hiddenPromptText = typeof hiddenPrompt === "string" ? hiddenPrompt.trim() : "";
|
||||
const hiddenPromptPrefix = hiddenPromptText ? `${hiddenPromptText}\n\n` : "";
|
||||
const uiMessagesForModel = hiddenPromptText
|
||||
? applyHiddenPromptToMessages(uiMessages, hiddenPromptText)
|
||||
: uiMessages;
|
||||
|
||||
const { longitude, latitude, city, country } = geolocation(request);
|
||||
|
||||
@ -797,8 +824,8 @@ export async function POST(request: Request) {
|
||||
console.log(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`);
|
||||
|
||||
// 只传递最后一条用户消息,避免上下文历史干扰生图效果
|
||||
const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop();
|
||||
const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessages;
|
||||
const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop();
|
||||
const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessagesForModel;
|
||||
console.log("[Chat API] Gemini: Using only last user message (no history context)");
|
||||
|
||||
// 转换消息:gemini-image 需要 base64 格式,huanke-gemini 需要 URL 格式
|
||||
@ -926,7 +953,7 @@ export async function POST(request: Request) {
|
||||
console.log("[Chat API] Executing Volcano Vision failover...");
|
||||
|
||||
// 转换消息(火山引擎会自动处理 Base64)
|
||||
const volcanoMessages = await convertMessagesWithImages(uiMessages, "volcano");
|
||||
const volcanoMessages = await convertMessagesWithImages(uiMessagesForModel, "volcano");
|
||||
|
||||
// 调用火山引擎生成(故障转移时只生成 1 张,保持与 Gemini 一致)
|
||||
const volcanoResult = await (volcanoLanguageModel as any).doGenerate({
|
||||
@ -1091,8 +1118,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// 只传递最后一条用户消息
|
||||
const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop();
|
||||
let messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessages;
|
||||
const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop();
|
||||
let messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessagesForModel;
|
||||
console.log("[Chat API] Alibaba: Using only last user message");
|
||||
|
||||
// 🆕 如果有智能模式优化后的提示词,替换消息中的文本(仅用于API调用)
|
||||
@ -1102,7 +1129,7 @@ export async function POST(request: Request) {
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return { ...part, text: smartAutoOptimizedPrompt! };
|
||||
return { ...part, text: `${hiddenPromptPrefix}${smartAutoOptimizedPrompt!}`.trim() };
|
||||
}
|
||||
return part;
|
||||
}),
|
||||
@ -1248,13 +1275,13 @@ export async function POST(request: Request) {
|
||||
|
||||
// 🆕 检测是否有标注图片,如果有则在消息中注入系统提示
|
||||
const hasAnnotation = hasAnnotatedImage(uiMessages);
|
||||
let messagesForVolcano = uiMessages;
|
||||
let messagesForVolcano = uiMessagesForModel;
|
||||
|
||||
if (hasAnnotation) {
|
||||
console.log("[Chat API] 🎨 Volcano Vision: 检测到标注图片,将注入系统提示");
|
||||
// 创建消息副本,修改最后一条用户消息的文本
|
||||
messagesForVolcano = uiMessages.map((msg, idx) => {
|
||||
if (msg.role === "user" && idx === uiMessages.length - 1) {
|
||||
messagesForVolcano = messagesForVolcano.map((msg, idx) => {
|
||||
if (msg.role === "user" && idx === messagesForVolcano.length - 1) {
|
||||
// 修改最后一条用户消息
|
||||
const modifiedParts = msg.parts.map((part) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
@ -1429,7 +1456,7 @@ export async function POST(request: Request) {
|
||||
execute: async ({ writer }) => {
|
||||
try {
|
||||
// 千问视频直接使用原始 URL,不转换为 base64
|
||||
const modelMessages = convertToModelMessages(uiMessages);
|
||||
const modelMessages = convertToModelMessages(uiMessagesForModel);
|
||||
console.log("[Chat API] Qianwen video messages prepared, count:", modelMessages.length);
|
||||
|
||||
// 调用 doGenerate 获取视频
|
||||
@ -1543,7 +1570,7 @@ export async function POST(request: Request) {
|
||||
console.log("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志
|
||||
|
||||
// 转换消息,对于 Gemini 和千问视频将图片 URL 转换为 base64
|
||||
const convertedMessages = await convertMessagesWithImages(uiMessages, (languageModel as any).provider || "");
|
||||
const convertedMessages = await convertMessagesWithImages(uiMessagesForModel, (languageModel as any).provider || "");
|
||||
console.log("[Chat API] Messages converted, count:", convertedMessages.length); // 调试日志
|
||||
|
||||
// 🆕 检测是否是视频模型
|
||||
@ -1586,13 +1613,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 备用方案1:content 字符串
|
||||
else if (typeof lastUserMessage.content === "string") {
|
||||
prompt = lastUserMessage.content;
|
||||
}
|
||||
// 备用方案2:content 数组
|
||||
else if (Array.isArray(lastUserMessage.content)) {
|
||||
for (const part of lastUserMessage.content) {
|
||||
// 备用方案1:content 字符串/数组
|
||||
const legacyContent = (lastUserMessage as any).content;
|
||||
if (typeof legacyContent === "string") {
|
||||
prompt = legacyContent;
|
||||
} else if (Array.isArray(legacyContent)) {
|
||||
for (const part of legacyContent) {
|
||||
if (typeof part === "string") {
|
||||
prompt += part;
|
||||
} else if (part.type === "text") {
|
||||
@ -1601,9 +1627,8 @@ export async function POST(request: Request) {
|
||||
imageUrls.push((part as any).image);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 备用方案3:experimental_attachments
|
||||
else if ((lastUserMessage as any).experimental_attachments) {
|
||||
} else if ((lastUserMessage as any).experimental_attachments) {
|
||||
// 备用方案2:experimental_attachments
|
||||
for (const attachment of (lastUserMessage as any).experimental_attachments) {
|
||||
if (attachment.url && attachment.contentType?.startsWith("image/")) {
|
||||
imageUrls.push(attachment.url);
|
||||
|
||||
@ -49,6 +49,7 @@ export const postRequestBodySchema = z.object({
|
||||
role: z.enum(["user"]),
|
||||
parts: z.array(partSchema),
|
||||
}),
|
||||
hiddenPrompt: z.string().max(4000).optional(),
|
||||
// 支持预定义模型名称或数据库中的模型 UUID
|
||||
selectedChatModel: z.string().min(1).max(128),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { generateText } from "ai";
|
||||
import { getEnabledAiModelsWithProvider, getAiProviderById } from "@/lib/db/queries";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
console.log("[Auto-title] API called");
|
||||
@ -21,22 +21,25 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Image URL is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 从数据库获取支持视觉的模型(查找包含 qwen-vl 或 vision 的模型)
|
||||
// 从数据库获取支持视觉的模型(优先使用 supportsVision)
|
||||
const modelsData = await getEnabledAiModelsWithProvider();
|
||||
console.log("[Auto-title] Available models count:", modelsData.length);
|
||||
console.log("[Auto-title] All model IDs:", modelsData.map(m => m.model.modelId));
|
||||
|
||||
// 查找千问VL模型(支持多种命名格式)
|
||||
const visionModelData = modelsData.find(m => {
|
||||
const id = m.model.modelId?.toLowerCase() || '';
|
||||
const name = m.model.displayName?.toLowerCase() || '';
|
||||
return id.includes('qwen-vl') ||
|
||||
id.includes('qwen3-vl') ||
|
||||
id.includes('qwen2.5-vl') ||
|
||||
id.includes('vision') ||
|
||||
name.includes('vl') ||
|
||||
name.includes('视觉');
|
||||
});
|
||||
let visionModelData = modelsData.find(m => m.model.supportsVision);
|
||||
if (!visionModelData) {
|
||||
// 兼容旧配置:根据模型名称兜底匹配
|
||||
visionModelData = modelsData.find(m => {
|
||||
const id = m.model.modelId?.toLowerCase() || '';
|
||||
const name = m.model.displayName?.toLowerCase() || '';
|
||||
return id.includes('qwen-vl') ||
|
||||
id.includes('qwen3-vl') ||
|
||||
id.includes('qwen2.5-vl') ||
|
||||
id.includes('vision') ||
|
||||
name.includes('vl') ||
|
||||
name.includes('视觉');
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[Auto-title] Found vision model:", visionModelData?.model.modelId, visionModelData?.model.displayName);
|
||||
|
||||
@ -45,31 +48,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ title: "未命名画布" });
|
||||
}
|
||||
|
||||
// 直接使用返回的 provider 信息
|
||||
const provider = visionModelData.provider;
|
||||
const visionModel = visionModelData.model;
|
||||
if (!provider || !provider.apiKey || !provider.baseUrl) {
|
||||
console.log("[Auto-title] Provider not configured");
|
||||
const model = await getLanguageModelById(visionModel.id);
|
||||
if (!model) {
|
||||
console.log("[Auto-title] Failed to load vision model");
|
||||
return NextResponse.json({ title: "未命名画布" });
|
||||
}
|
||||
|
||||
console.log("[Auto-title] Using provider:", provider.name, "baseUrl:", provider.baseUrl);
|
||||
|
||||
// 构造 Prompt
|
||||
const prompt = "请用2-4个字概括这张图片的主体内容,作为文件标题。直接返回标题,不要包含任何标点符号或其他文字。";
|
||||
|
||||
// 使用数据库配置的 provider
|
||||
const openai = createOpenAI({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseUrl,
|
||||
});
|
||||
|
||||
// 直接使用数据库中的模型 ID(阿里云支持 qwen3-vl-plus 等新模型 ID)
|
||||
const actualModelId = visionModel.modelId;
|
||||
console.log("[Auto-title] Using model ID:", actualModelId);
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai(actualModelId),
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@ -79,10 +69,16 @@ export async function POST(request: NextRequest) {
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
maxTokens: 50,
|
||||
});
|
||||
|
||||
console.log("[Auto-title] Generated title:", text.trim());
|
||||
return NextResponse.json({ title: text.trim() });
|
||||
let title = (text || "").trim();
|
||||
title = title.replace(/[\s\p{P}\p{S}]+/gu, "");
|
||||
if (title.length > 4) title = title.slice(0, 4);
|
||||
if (title.length < 2) title = "新画布";
|
||||
console.log("[Auto-title] Generated title:", title);
|
||||
return NextResponse.json({ title });
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Auto-title] Error:", error);
|
||||
|
||||
@ -141,6 +141,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
|
||||
// 🆕 标签悬停预览状态
|
||||
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
|
||||
@ -548,6 +549,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
imageParams: imageParamsRef.current,
|
||||
lastMessage: request.messages.at(-1),
|
||||
});
|
||||
const hiddenPrompt = hiddenPromptRef.current || undefined;
|
||||
hiddenPromptRef.current = null;
|
||||
return {
|
||||
body: {
|
||||
id: rawId,
|
||||
@ -556,6 +559,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
selectedVisibilityType: "private",
|
||||
videoParams: undefined, // 必须传递,即使是 undefined
|
||||
imageParams: imageParamsRef.current || undefined, // 传递图片参数
|
||||
hiddenPrompt,
|
||||
...request.body,
|
||||
},
|
||||
};
|
||||
@ -787,7 +791,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const hasAnnotatedImages = annotatedNodes.length > 0;
|
||||
|
||||
// 添加文件部分(作为 file 类型的 part)
|
||||
// 🆕 收集物品信息用于构建增强 prompt
|
||||
// 🆕 收集物品信息用于构建隐藏提示
|
||||
const pickedObjectInfos: string[] = [];
|
||||
|
||||
effectiveAttachments.forEach((a) => {
|
||||
@ -810,21 +814,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
}
|
||||
});
|
||||
|
||||
// 添加文本部分
|
||||
// 添加文本部分(用户可见)
|
||||
let textContent = input.trim() || (effectiveAttachments.length > 0 ? "请处理这张图片" : "");
|
||||
|
||||
// 🆕 如果有拾取的物品,在文本前添加物品信息
|
||||
if (pickedObjectInfos.length > 0 && textContent) {
|
||||
|
||||
// 🆕 隐藏提示词(不在 UI 中展示)
|
||||
const hiddenSegments: string[] = [];
|
||||
if (pickedObjectInfos.length > 0) {
|
||||
const objectsPrompt = pickedObjectInfos.join('\n');
|
||||
textContent = `【用户指定的物品】\n${objectsPrompt}\n\n【用户指令】${textContent}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`;
|
||||
console.log('[CanvasChat] 检测到拾取物品,已添加物品信息:', pickedObjectInfos.length, '个');
|
||||
hiddenSegments.push(
|
||||
`【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`
|
||||
);
|
||||
console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个');
|
||||
}
|
||||
|
||||
// 🆕 如果有标注的图片,在文本中添加提示
|
||||
if (hasAnnotatedImages && textContent) {
|
||||
textContent = `【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n${textContent}`;
|
||||
console.log('[CanvasChat] 检测到标注图片,已添加标注提示');
|
||||
if (hasAnnotatedImages) {
|
||||
hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】");
|
||||
console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示');
|
||||
}
|
||||
const hiddenPrompt = hiddenSegments.join('\n\n').trim();
|
||||
hiddenPromptRef.current = hiddenPrompt || null;
|
||||
|
||||
if (textContent) {
|
||||
parts.push({ type: "text" as const, text: textContent });
|
||||
@ -1070,7 +1077,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
onClick={() => {
|
||||
// 创建新画布并导航
|
||||
const newId = crypto.randomUUID();
|
||||
router.push(`/canvas/${newId}`);
|
||||
const targetUrl = `/canvas/${newId}`;
|
||||
setTimeout(() => {
|
||||
window.location.assign(targetUrl);
|
||||
}, 100);
|
||||
}}
|
||||
title="新建画布"
|
||||
>
|
||||
|
||||
@ -52,9 +52,12 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// 使用 router.push 并关闭面板,让页面组件的 useEffect 处理加载
|
||||
router.push(`/canvas/${id}`);
|
||||
onClose(); // 关闭文件列表面板,切换到聊天面板
|
||||
const targetUrl = `/canvas/${id}`;
|
||||
onClose(); // 关闭文件列表面板
|
||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||
setTimeout(() => {
|
||||
window.location.assign(targetUrl);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 删除画布
|
||||
|
||||
@ -10,6 +10,8 @@ import { TextNode } from "./nodes/TextNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
// 性能优化:判断节点是否在视口内
|
||||
@ -45,6 +47,7 @@ export function CanvasWorkspace() {
|
||||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const tool = useCanvasStore(canvasSelectors.tool);
|
||||
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
|
||||
|
||||
// 性能优化:监听容器大小变化
|
||||
React.useEffect(() => {
|
||||
@ -77,9 +80,13 @@ export function CanvasWorkspace() {
|
||||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const moveNode = useCanvasStore((state) => state.moveNode);
|
||||
const resizeNode = useCanvasStore((state) => state.resizeNode);
|
||||
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId);
|
||||
const centerTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// 处理画布点击(取消选择)
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
@ -112,6 +119,39 @@ export function CanvasWorkspace() {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// 新增图片后自动居中展示
|
||||
const centerNodeInView = useCallback((nodeId: string) => {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) return;
|
||||
const width = containerSize.width || 0;
|
||||
const height = containerSize.height || 0;
|
||||
if (width === 0 || height === 0) return;
|
||||
const zoom = viewport.zoom;
|
||||
const centerX = node.position.x + node.size.width / 2;
|
||||
const centerY = node.position.y + node.size.height / 2;
|
||||
const newX = width / 2 - centerX * zoom;
|
||||
const newY = height / 2 - centerY * zoom;
|
||||
setViewport({ x: newX, y: newY });
|
||||
}, [containerSize.width, containerSize.height, nodes, setViewport, viewport.zoom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lastAddedNodeId) return;
|
||||
if (containerSize.width === 0 || containerSize.height === 0) return;
|
||||
if (centerTimeoutRef.current) {
|
||||
window.clearTimeout(centerTimeoutRef.current);
|
||||
}
|
||||
centerTimeoutRef.current = window.setTimeout(() => {
|
||||
centerNodeInView(lastAddedNodeId);
|
||||
clearLastAddedNodeId();
|
||||
}, 80);
|
||||
return () => {
|
||||
if (centerTimeoutRef.current) {
|
||||
window.clearTimeout(centerTimeoutRef.current);
|
||||
centerTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [lastAddedNodeId, centerNodeInView, clearLastAddedNodeId, containerSize.width, containerSize.height]);
|
||||
|
||||
// 处理滚轮缩放
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
@ -142,6 +182,108 @@ export function CanvasWorkspace() {
|
||||
}
|
||||
}, [viewport, setViewport]);
|
||||
|
||||
const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxSize = 500;
|
||||
let width = img.naturalWidth;
|
||||
let height = img.naturalHeight;
|
||||
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);
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width, height });
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width: 300, height: 300 });
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (e.dataTransfer?.types?.includes?.("Files")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/"));
|
||||
if (files.length === 0) return;
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const baseX = rect ? (e.clientX - rect.left - viewport.x) / viewport.zoom : 100;
|
||||
const baseY = rect ? (e.clientY - rect.top - viewport.y) / viewport.zoom : 100;
|
||||
const spacing = 20;
|
||||
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index];
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("图片大小不能超过 20MB");
|
||||
continue;
|
||||
}
|
||||
|
||||
const dimensions = await getImageDimensionsFromFile(file);
|
||||
const placeholderId = addNode({
|
||||
type: "image",
|
||||
position: { x: baseX + index * spacing, y: baseY + index * spacing },
|
||||
size: { width: dimensions.width, height: dimensions.height },
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: file.name || "拖拽上传",
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await imageGenerationService.uploadImage(file);
|
||||
if (result.success && result.data?.url) {
|
||||
updateNode(placeholderId, {
|
||||
data: {
|
||||
src: result.data.url,
|
||||
status: "completed",
|
||||
prompt: file.name || "拖拽上传",
|
||||
},
|
||||
});
|
||||
toast.success("图片已添加到画布");
|
||||
} else {
|
||||
updateNode(placeholderId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "failed",
|
||||
prompt: file.name || "拖拽上传",
|
||||
},
|
||||
});
|
||||
toast.error(result.error || "图片上传失败");
|
||||
}
|
||||
} catch (error) {
|
||||
updateNode(placeholderId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "failed",
|
||||
prompt: file.name || "拖拽上传",
|
||||
},
|
||||
});
|
||||
toast.error("图片上传失败");
|
||||
}
|
||||
}
|
||||
}, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
||||
|
||||
// 渲染节点
|
||||
const renderNode = (node: CanvasNode) => {
|
||||
const isSelected = selectedNodeIds.includes(node.id);
|
||||
@ -231,6 +373,8 @@ export function CanvasWorkspace() {
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
|
||||
<div
|
||||
|
||||
@ -337,6 +337,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
}
|
||||
if (isProcessing) {
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
|
||||
// 对话框类型的操作
|
||||
if (action === 'angle') {
|
||||
@ -358,6 +362,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
|
||||
const actionName = getActionName(action);
|
||||
|
||||
setIsProcessing(true);
|
||||
// 先创建一个"处理中"状态的节点,显示在原图右侧
|
||||
const newNodeId = addNode({
|
||||
type: 'image',
|
||||
@ -438,6 +443,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
});
|
||||
}
|
||||
toast.error("处理失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -511,8 +518,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
// 尺寸调整执行
|
||||
const handleResize = async () => {
|
||||
if (!data.src) return;
|
||||
if (isProcessing) {
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
|
||||
setResizeDialogOpen(false);
|
||||
setIsProcessing(true);
|
||||
|
||||
// 创建处理中节点
|
||||
const newNodeId = addNode({
|
||||
@ -566,6 +578,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
},
|
||||
});
|
||||
toast.error('尺寸调整失败');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -861,7 +875,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
</Dialog>
|
||||
|
||||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||||
{isSelected && data.src && !isProcessing && typeof document !== 'undefined' && createPortal(
|
||||
{isSelected && data.src && typeof document !== 'undefined' && createPortal(
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div
|
||||
className="fixed flex items-center gap-0.5 rounded-full shadow-xl px-2 py-1 z-[9999] bg-white/95 border border-gray-200 text-gray-800 dark:bg-gray-900/95 dark:border-gray-700 dark:text-gray-100"
|
||||
@ -879,7 +893,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => {
|
||||
addPendingChatAttachment({
|
||||
url: data.src!,
|
||||
@ -901,7 +916,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-orange-50 text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300 dark:hover:bg-orange-900/30"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-orange-50 text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300 dark:hover:bg-orange-900/30 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => {
|
||||
setPickerMode(true, node.id);
|
||||
toast.info('点击图片中的物品进行拾取');
|
||||
@ -922,7 +938,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('enhance')}
|
||||
>
|
||||
<span className="text-[10px] font-medium text-gray-500 border border-gray-300 rounded px-1 dark:text-gray-200 dark:border-gray-600">HD</span>
|
||||
@ -937,7 +954,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('rmbg')}
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
@ -952,7 +970,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('watermark')}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
@ -967,7 +986,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('scene-fusion')}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
@ -982,7 +1002,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('angle')}
|
||||
>
|
||||
<Move3D className="h-4 w-4" />
|
||||
@ -997,7 +1018,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('extend')}
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
@ -1012,7 +1034,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => handleAction('resize')}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
@ -1027,7 +1050,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10", data.hasAnnotations && "text-primary")}
|
||||
disabled={isProcessing}
|
||||
className={cn("h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none", data.hasAnnotations && "text-primary")}
|
||||
onClick={() => handleAction('annotate')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
@ -1042,7 +1066,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
@ -1057,7 +1082,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-red-50 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/30"
|
||||
disabled={isProcessing}
|
||||
className="h-8 w-8 rounded-full hover:bg-red-50 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/30 disabled:opacity-50 disabled:pointer-events-none"
|
||||
onClick={() => deleteNode(node.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@ -83,6 +83,9 @@ interface CanvasStore {
|
||||
isPickerLoading: boolean;
|
||||
pickedObject: PickedObject | null;
|
||||
pendingPickedObjects: PickedObject[];
|
||||
|
||||
// 🆕 新增节点(用于自动聚焦)
|
||||
lastAddedNodeId: string | null;
|
||||
|
||||
// 项目操作
|
||||
setProject: (id: string | null, title?: string) => void;
|
||||
@ -148,6 +151,9 @@ interface CanvasStore {
|
||||
setPickedObject: (obj: PickedObject | null) => void;
|
||||
addPendingPickedObject: (obj: PickedObject) => void;
|
||||
clearPendingPickedObjects: () => void;
|
||||
|
||||
// 🆕 自动聚焦控制
|
||||
clearLastAddedNodeId: () => void;
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasStore>()(
|
||||
@ -174,6 +180,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
isPickerLoading: false,
|
||||
pickedObject: null,
|
||||
pendingPickedObjects: [],
|
||||
lastAddedNodeId: null,
|
||||
|
||||
// 项目操作
|
||||
setProject: (id, title) => set((state) => {
|
||||
@ -187,17 +194,68 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
|
||||
// 节点操作
|
||||
addNode: (nodeData) => {
|
||||
const resolveImagePosition = (
|
||||
basePosition: Position,
|
||||
size: Size,
|
||||
nodes: CanvasNode[]
|
||||
): Position => {
|
||||
if (nodeData.type !== 'image') return basePosition;
|
||||
const padding = 20;
|
||||
const width = size?.width || 300;
|
||||
const height = size?.height || 300;
|
||||
const stepX = width + padding;
|
||||
const stepY = height + padding;
|
||||
|
||||
const overlaps = (pos: Position) => nodes.some(n => {
|
||||
if (n.type !== 'image') return false;
|
||||
const nSize = n.size || { width: 300, height: 300 };
|
||||
return !(
|
||||
pos.x + width + padding <= n.position.x ||
|
||||
pos.x >= n.position.x + nSize.width + padding ||
|
||||
pos.y + height + padding <= n.position.y ||
|
||||
pos.y >= n.position.y + nSize.height + padding
|
||||
);
|
||||
});
|
||||
|
||||
if (!overlaps(basePosition)) return basePosition;
|
||||
|
||||
const maxRings = 20;
|
||||
for (let ring = 1; ring <= maxRings; ring++) {
|
||||
for (let dx = -ring; dx <= ring; dx++) {
|
||||
for (let dy = -ring; dy <= ring; dy++) {
|
||||
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
|
||||
const candidate = {
|
||||
x: basePosition.x + dx * stepX,
|
||||
y: basePosition.y + dy * stepY,
|
||||
};
|
||||
if (!overlaps(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return basePosition;
|
||||
};
|
||||
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const basePosition = nodeData.position || { x: 0, y: 0 };
|
||||
const nodeSize = nodeData.size || { width: 300, height: 300 };
|
||||
const finalPosition = resolveImagePosition(basePosition, nodeSize, get().nodes);
|
||||
const node: CanvasNode = {
|
||||
...nodeData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
position: finalPosition,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
state.nodes.push(node);
|
||||
if (node.type === 'image') {
|
||||
state.lastAddedNodeId = id;
|
||||
}
|
||||
});
|
||||
|
||||
// 添加到历史
|
||||
@ -474,6 +532,10 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
clearPendingPickedObjects: () => set((state) => {
|
||||
state.pendingPickedObjects = [];
|
||||
}),
|
||||
|
||||
clearLastAddedNodeId: () => set((state) => {
|
||||
state.lastAddedNodeId = null;
|
||||
}),
|
||||
})),
|
||||
{
|
||||
name: 'canvas-storage',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user