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:
Your Name 2026-02-03 16:33:51 +08:00
parent 335dc925d5
commit bb4f51d198
8 changed files with 350 additions and 83 deletions

View File

@ -104,6 +104,26 @@ function hasAnnotatedImage(messages: ChatMessage[]): boolean {
*/ */
const ANNOTATION_SYSTEM_PROMPT = "【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。"; 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) * 注意:gemini-image 和 huanke-gemini 都需要 URL 格式(不是 Base64)
@ -316,11 +336,13 @@ export async function POST(request: Request) {
message, message,
selectedChatModel, selectedChatModel,
selectedVisibilityType, selectedVisibilityType,
hiddenPrompt,
}: { }: {
id: string; id: string;
message: ChatMessage; message: ChatMessage;
selectedChatModel: ChatModel["id"]; selectedChatModel: ChatModel["id"];
selectedVisibilityType: VisibilityType; selectedVisibilityType: VisibilityType;
hiddenPrompt?: string;
} = requestBody; } = requestBody;
let finalModelId = selectedChatModel; let finalModelId = selectedChatModel;
@ -599,6 +621,11 @@ export async function POST(request: Request) {
} }
const uiMessages = [...convertToUIMessages(messagesFromDb), message]; 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); 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}`); console.log(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`);
// 只传递最后一条用户消息,避免上下文历史干扰生图效果 // 只传递最后一条用户消息,避免上下文历史干扰生图效果
const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop(); const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop();
const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessages; const messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessagesForModel;
console.log("[Chat API] Gemini: Using only last user message (no history context)"); console.log("[Chat API] Gemini: Using only last user message (no history context)");
// 转换消息:gemini-image 需要 base64 格式,huanke-gemini 需要 URL 格式 // 转换消息:gemini-image 需要 base64 格式,huanke-gemini 需要 URL 格式
@ -926,7 +953,7 @@ export async function POST(request: Request) {
console.log("[Chat API] Executing Volcano Vision failover..."); console.log("[Chat API] Executing Volcano Vision failover...");
// 转换消息(火山引擎会自动处理 Base64) // 转换消息(火山引擎会自动处理 Base64)
const volcanoMessages = await convertMessagesWithImages(uiMessages, "volcano"); const volcanoMessages = await convertMessagesWithImages(uiMessagesForModel, "volcano");
// 调用火山引擎生成(故障转移时只生成 1 张,保持与 Gemini 一致) // 调用火山引擎生成(故障转移时只生成 1 张,保持与 Gemini 一致)
const volcanoResult = await (volcanoLanguageModel as any).doGenerate({ 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(); const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop();
let messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessages; let messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessagesForModel;
console.log("[Chat API] Alibaba: Using only last user message"); console.log("[Chat API] Alibaba: Using only last user message");
// 🆕 如果有智能模式优化后的提示词,替换消息中的文本(仅用于API调用) // 🆕 如果有智能模式优化后的提示词,替换消息中的文本(仅用于API调用)
@ -1102,7 +1129,7 @@ export async function POST(request: Request) {
...msg, ...msg,
parts: msg.parts.map((part) => { parts: msg.parts.map((part) => {
if (part.type === "text") { if (part.type === "text") {
return { ...part, text: smartAutoOptimizedPrompt! }; return { ...part, text: `${hiddenPromptPrefix}${smartAutoOptimizedPrompt!}`.trim() };
} }
return part; return part;
}), }),
@ -1248,13 +1275,13 @@ export async function POST(request: Request) {
// 🆕 检测是否有标注图片,如果有则在消息中注入系统提示 // 🆕 检测是否有标注图片,如果有则在消息中注入系统提示
const hasAnnotation = hasAnnotatedImage(uiMessages); const hasAnnotation = hasAnnotatedImage(uiMessages);
let messagesForVolcano = uiMessages; let messagesForVolcano = uiMessagesForModel;
if (hasAnnotation) { if (hasAnnotation) {
console.log("[Chat API] 🎨 Volcano Vision: 检测到标注图片,将注入系统提示"); console.log("[Chat API] 🎨 Volcano Vision: 检测到标注图片,将注入系统提示");
// 创建消息副本,修改最后一条用户消息的文本 // 创建消息副本,修改最后一条用户消息的文本
messagesForVolcano = uiMessages.map((msg, idx) => { messagesForVolcano = messagesForVolcano.map((msg, idx) => {
if (msg.role === "user" && idx === uiMessages.length - 1) { if (msg.role === "user" && idx === messagesForVolcano.length - 1) {
// 修改最后一条用户消息 // 修改最后一条用户消息
const modifiedParts = msg.parts.map((part) => { const modifiedParts = msg.parts.map((part) => {
if (part.type === "text" && part.text) { if (part.type === "text" && part.text) {
@ -1429,7 +1456,7 @@ export async function POST(request: Request) {
execute: async ({ writer }) => { execute: async ({ writer }) => {
try { try {
// 千问视频直接使用原始 URL,不转换为 base64 // 千问视频直接使用原始 URL,不转换为 base64
const modelMessages = convertToModelMessages(uiMessages); const modelMessages = convertToModelMessages(uiMessagesForModel);
console.log("[Chat API] Qianwen video messages prepared, count:", modelMessages.length); console.log("[Chat API] Qianwen video messages prepared, count:", modelMessages.length);
// 调用 doGenerate 获取视频 // 调用 doGenerate 获取视频
@ -1543,7 +1570,7 @@ export async function POST(request: Request) {
console.log("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志 console.log("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志
// 转换消息,对于 Gemini 和千问视频将图片 URL 转换为 base64 // 转换消息,对于 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); // 调试日志 console.log("[Chat API] Messages converted, count:", convertedMessages.length); // 调试日志
// 🆕 检测是否是视频模型 // 🆕 检测是否是视频模型
@ -1586,13 +1613,12 @@ export async function POST(request: Request) {
} }
} }
} }
// 备用方案1:content 字符串 // 备用方案1:content 字符串/数组
else if (typeof lastUserMessage.content === "string") { const legacyContent = (lastUserMessage as any).content;
prompt = lastUserMessage.content; if (typeof legacyContent === "string") {
} prompt = legacyContent;
// 备用方案2:content 数组 } else if (Array.isArray(legacyContent)) {
else if (Array.isArray(lastUserMessage.content)) { for (const part of legacyContent) {
for (const part of lastUserMessage.content) {
if (typeof part === "string") { if (typeof part === "string") {
prompt += part; prompt += part;
} else if (part.type === "text") { } else if (part.type === "text") {
@ -1601,9 +1627,8 @@ export async function POST(request: Request) {
imageUrls.push((part as any).image); imageUrls.push((part as any).image);
} }
} }
} } else if ((lastUserMessage as any).experimental_attachments) {
// 备用方案3:experimental_attachments // 备用方案2:experimental_attachments
else if ((lastUserMessage as any).experimental_attachments) {
for (const attachment of (lastUserMessage as any).experimental_attachments) { for (const attachment of (lastUserMessage as any).experimental_attachments) {
if (attachment.url && attachment.contentType?.startsWith("image/")) { if (attachment.url && attachment.contentType?.startsWith("image/")) {
imageUrls.push(attachment.url); imageUrls.push(attachment.url);

View File

@ -49,6 +49,7 @@ export const postRequestBodySchema = z.object({
role: z.enum(["user"]), role: z.enum(["user"]),
parts: z.array(partSchema), parts: z.array(partSchema),
}), }),
hiddenPrompt: z.string().max(4000).optional(),
// 支持预定义模型名称或数据库中的模型 UUID // 支持预定义模型名称或数据库中的模型 UUID
selectedChatModel: z.string().min(1).max(128), selectedChatModel: z.string().min(1).max(128),
selectedVisibilityType: z.enum(["public", "private"]), selectedVisibilityType: z.enum(["public", "private"]),

View File

@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { generateText } from "ai"; import { generateText } from "ai";
import { getEnabledAiModelsWithProvider, getAiProviderById } from "@/lib/db/queries"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { createOpenAI } from "@ai-sdk/openai"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
console.log("[Auto-title] API called"); 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 }); return NextResponse.json({ error: "Image URL is required" }, { status: 400 });
} }
// 从数据库获取支持视觉的模型(查找包含 qwen-vl 或 vision 的模型) // 从数据库获取支持视觉的模型(优先使用 supportsVision)
const modelsData = await getEnabledAiModelsWithProvider(); const modelsData = await getEnabledAiModelsWithProvider();
console.log("[Auto-title] Available models count:", modelsData.length); console.log("[Auto-title] Available models count:", modelsData.length);
console.log("[Auto-title] All model IDs:", modelsData.map(m => m.model.modelId)); console.log("[Auto-title] All model IDs:", modelsData.map(m => m.model.modelId));
// 查找千问VL模型(支持多种命名格式) let visionModelData = modelsData.find(m => m.model.supportsVision);
const visionModelData = modelsData.find(m => { if (!visionModelData) {
const id = m.model.modelId?.toLowerCase() || ''; // 兼容旧配置:根据模型名称兜底匹配
const name = m.model.displayName?.toLowerCase() || ''; visionModelData = modelsData.find(m => {
return id.includes('qwen-vl') || const id = m.model.modelId?.toLowerCase() || '';
id.includes('qwen3-vl') || const name = m.model.displayName?.toLowerCase() || '';
id.includes('qwen2.5-vl') || return id.includes('qwen-vl') ||
id.includes('vision') || id.includes('qwen3-vl') ||
name.includes('vl') || id.includes('qwen2.5-vl') ||
name.includes('视觉'); id.includes('vision') ||
}); name.includes('vl') ||
name.includes('视觉');
});
}
console.log("[Auto-title] Found vision model:", visionModelData?.model.modelId, visionModelData?.model.displayName); 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: "未命名画布" }); return NextResponse.json({ title: "未命名画布" });
} }
// 直接使用返回的 provider 信息
const provider = visionModelData.provider;
const visionModel = visionModelData.model; const visionModel = visionModelData.model;
if (!provider || !provider.apiKey || !provider.baseUrl) { const model = await getLanguageModelById(visionModel.id);
console.log("[Auto-title] Provider not configured"); if (!model) {
console.log("[Auto-title] Failed to load vision model");
return NextResponse.json({ title: "未命名画布" }); return NextResponse.json({ title: "未命名画布" });
} }
console.log("[Auto-title] Using provider:", provider.name, "baseUrl:", provider.baseUrl);
// 构造 Prompt // 构造 Prompt
const prompt = "请用2-4个字概括这张图片的主体内容,作为文件标题。直接返回标题,不要包含任何标点符号或其他文字。"; 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({ const { text } = await generateText({
model: openai(actualModelId), model,
messages: [ messages: [
{ {
role: 'user', 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()); let title = (text || "").trim();
return NextResponse.json({ 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) { } catch (error) {
console.error("[Auto-title] Error:", error); console.error("[Auto-title] Error:", error);

View File

@ -141,6 +141,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const hiddenPromptRef = useRef<string | null>(null);
// 🆕 标签悬停预览状态 // 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null); const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
@ -548,6 +549,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageParams: imageParamsRef.current, imageParams: imageParamsRef.current,
lastMessage: request.messages.at(-1), lastMessage: request.messages.at(-1),
}); });
const hiddenPrompt = hiddenPromptRef.current || undefined;
hiddenPromptRef.current = null;
return { return {
body: { body: {
id: rawId, id: rawId,
@ -556,6 +559,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
selectedVisibilityType: "private", selectedVisibilityType: "private",
videoParams: undefined, // 必须传递,即使是 undefined videoParams: undefined, // 必须传递,即使是 undefined
imageParams: imageParamsRef.current || undefined, // 传递图片参数 imageParams: imageParamsRef.current || undefined, // 传递图片参数
hiddenPrompt,
...request.body, ...request.body,
}, },
}; };
@ -787,7 +791,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const hasAnnotatedImages = annotatedNodes.length > 0; const hasAnnotatedImages = annotatedNodes.length > 0;
// 添加文件部分(作为 file 类型的 part) // 添加文件部分(作为 file 类型的 part)
// 🆕 收集物品信息用于构建增强 prompt // 🆕 收集物品信息用于构建隐藏提示
const pickedObjectInfos: string[] = []; const pickedObjectInfos: string[] = [];
effectiveAttachments.forEach((a) => { effectiveAttachments.forEach((a) => {
@ -810,21 +814,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
} }
}); });
// 添加文本部分 // 添加文本部分(用户可见)
let textContent = input.trim() || (effectiveAttachments.length > 0 ? "请处理这张图片" : ""); let textContent = input.trim() || (effectiveAttachments.length > 0 ? "请处理这张图片" : "");
// 🆕 如果有拾取的物品,在文本前添加物品信息 // 🆕 隐藏提示词(不在 UI 中展示)
if (pickedObjectInfos.length > 0 && textContent) { const hiddenSegments: string[] = [];
if (pickedObjectInfos.length > 0) {
const objectsPrompt = pickedObjectInfos.join('\n'); const objectsPrompt = pickedObjectInfos.join('\n');
textContent = `【用户指定的物品】\n${objectsPrompt}\n\n【用户指令】${textContent}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`; hiddenSegments.push(
console.log('[CanvasChat] 检测到拾取物品,已添加物品信息:', pickedObjectInfos.length, '个'); `【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`
);
console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个');
} }
if (hasAnnotatedImages) {
// 🆕 如果有标注的图片,在文本中添加提示 hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】");
if (hasAnnotatedImages && textContent) { console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示');
textContent = `【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n${textContent}`;
console.log('[CanvasChat] 检测到标注图片,已添加标注提示');
} }
const hiddenPrompt = hiddenSegments.join('\n\n').trim();
hiddenPromptRef.current = hiddenPrompt || null;
if (textContent) { if (textContent) {
parts.push({ type: "text" as const, text: textContent }); parts.push({ type: "text" as const, text: textContent });
@ -1070,7 +1077,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
onClick={() => { onClick={() => {
// 创建新画布并导航 // 创建新画布并导航
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
router.push(`/canvas/${newId}`); const targetUrl = `/canvas/${newId}`;
setTimeout(() => {
window.location.assign(targetUrl);
}, 100);
}} }}
title="新建画布" title="新建画布"
> >

View File

@ -52,9 +52,12 @@ export function CanvasFileListPanel({ currentId, onClose }: CanvasFileListPanelP
onClose(); onClose();
return; return;
} }
// 使用 router.push 并关闭面板,让页面组件的 useEffect 处理加载 const targetUrl = `/canvas/${id}`;
router.push(`/canvas/${id}`); onClose(); // 关闭文件列表面板
onClose(); // 关闭文件列表面板,切换到聊天面板 // 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => {
window.location.assign(targetUrl);
}, 100);
}; };
// 删除画布 // 删除画布

View File

@ -10,6 +10,8 @@ import { TextNode } from "./nodes/TextNode";
import { VideoNode } from "./nodes/VideoNode"; import { VideoNode } from "./nodes/VideoNode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas";
import { Minus, Plus } from "lucide-react"; import { Minus, Plus } from "lucide-react";
// 性能优化:判断节点是否在视口内 // 性能优化:判断节点是否在视口内
@ -45,6 +47,7 @@ export function CanvasWorkspace() {
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const viewport = useCanvasStore(canvasSelectors.viewport); const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool); const tool = useCanvasStore(canvasSelectors.tool);
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
// 性能优化:监听容器大小变化 // 性能优化:监听容器大小变化
React.useEffect(() => { React.useEffect(() => {
@ -77,9 +80,13 @@ export function CanvasWorkspace() {
const setViewport = useCanvasStore((state) => state.setViewport); const setViewport = useCanvasStore((state) => state.setViewport);
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const clearSelection = useCanvasStore((state) => state.clearSelection); const clearSelection = useCanvasStore((state) => state.clearSelection);
const addNode = useCanvasStore((state) => state.addNode);
const moveNode = useCanvasStore((state) => state.moveNode); const moveNode = useCanvasStore((state) => state.moveNode);
const resizeNode = useCanvasStore((state) => state.resizeNode); const resizeNode = useCanvasStore((state) => state.resizeNode);
const pushHistory = useCanvasStore((state) => state.pushHistory); 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) => { const handleCanvasClick = useCallback((e: React.MouseEvent) => {
@ -112,6 +119,39 @@ export function CanvasWorkspace() {
setIsPanning(false); 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) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
@ -142,6 +182,108 @@ export function CanvasWorkspace() {
} }
}, [viewport, setViewport]); }, [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 renderNode = (node: CanvasNode) => {
const isSelected = selectedNodeIds.includes(node.id); const isSelected = selectedNodeIds.includes(node.id);
@ -231,6 +373,8 @@ export function CanvasWorkspace() {
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onWheel={handleWheel} onWheel={handleWheel}
onDragOver={handleDragOver}
onDrop={handleDrop}
> >
{/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */} {/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
<div <div

View File

@ -337,6 +337,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
toast.error("请先上传图片"); toast.error("请先上传图片");
return; return;
} }
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
// 对话框类型的操作 // 对话框类型的操作
if (action === 'angle') { if (action === 'angle') {
@ -358,6 +362,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const actionName = getActionName(action); const actionName = getActionName(action);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧 // 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({ const newNodeId = addNode({
type: 'image', type: 'image',
@ -438,6 +443,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}); });
} }
toast.error("处理失败,请重试"); toast.error("处理失败,请重试");
} finally {
setIsProcessing(false);
} }
}; };
@ -511,8 +518,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
// 尺寸调整执行 // 尺寸调整执行
const handleResize = async () => { const handleResize = async () => {
if (!data.src) return; if (!data.src) return;
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setResizeDialogOpen(false); setResizeDialogOpen(false);
setIsProcessing(true);
// 创建处理中节点 // 创建处理中节点
const newNodeId = addNode({ const newNodeId = addNode({
@ -566,6 +578,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}, },
}); });
toast.error('尺寸调整失败'); toast.error('尺寸调整失败');
} finally {
setIsProcessing(false);
} }
}; };
@ -861,7 +875,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</Dialog> </Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && data.src && !isProcessing && typeof document !== 'undefined' && createPortal( {isSelected && data.src && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div <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" 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 <Button
variant="ghost" variant="ghost"
size="icon" 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={() => { onClick={() => {
addPendingChatAttachment({ addPendingChatAttachment({
url: data.src!, url: data.src!,
@ -901,7 +916,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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={() => { onClick={() => {
setPickerMode(true, node.id); setPickerMode(true, node.id);
toast.info('点击图片中的物品进行拾取'); toast.info('点击图片中的物品进行拾取');
@ -922,7 +938,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} 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> <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 <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('rmbg')}
> >
<Scissors className="h-4 w-4" /> <Scissors className="h-4 w-4" />
@ -952,7 +970,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('watermark')}
> >
<Eraser className="h-4 w-4" /> <Eraser className="h-4 w-4" />
@ -967,7 +986,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('scene-fusion')}
> >
<Layers className="h-4 w-4" /> <Layers className="h-4 w-4" />
@ -982,7 +1002,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('angle')}
> >
<Move3D className="h-4 w-4" /> <Move3D className="h-4 w-4" />
@ -997,7 +1018,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('extend')}
> >
<Expand className="h-4 w-4" /> <Expand className="h-4 w-4" />
@ -1012,7 +1034,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('resize')}
> >
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
@ -1027,7 +1050,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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')} onClick={() => handleAction('annotate')}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
@ -1042,7 +1066,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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} onClick={handleDownload}
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
@ -1057,7 +1082,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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)} onClick={() => deleteNode(node.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View File

@ -84,6 +84,9 @@ interface CanvasStore {
pickedObject: PickedObject | null; pickedObject: PickedObject | null;
pendingPickedObjects: PickedObject[]; pendingPickedObjects: PickedObject[];
// 🆕 新增节点(用于自动聚焦)
lastAddedNodeId: string | null;
// 项目操作 // 项目操作
setProject: (id: string | null, title?: string) => void; setProject: (id: string | null, title?: string) => void;
setProjectTitle: (title: string) => void; setProjectTitle: (title: string) => void;
@ -148,6 +151,9 @@ interface CanvasStore {
setPickedObject: (obj: PickedObject | null) => void; setPickedObject: (obj: PickedObject | null) => void;
addPendingPickedObject: (obj: PickedObject) => void; addPendingPickedObject: (obj: PickedObject) => void;
clearPendingPickedObjects: () => void; clearPendingPickedObjects: () => void;
// 🆕 自动聚焦控制
clearLastAddedNodeId: () => void;
} }
export const useCanvasStore = create<CanvasStore>()( export const useCanvasStore = create<CanvasStore>()(
@ -174,6 +180,7 @@ export const useCanvasStore = create<CanvasStore>()(
isPickerLoading: false, isPickerLoading: false,
pickedObject: null, pickedObject: null,
pendingPickedObjects: [], pendingPickedObjects: [],
lastAddedNodeId: null,
// 项目操作 // 项目操作
setProject: (id, title) => set((state) => { setProject: (id, title) => set((state) => {
@ -187,17 +194,68 @@ export const useCanvasStore = create<CanvasStore>()(
// 节点操作 // 节点操作
addNode: (nodeData) => { 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 id = generateId();
const now = new Date(); 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 = { const node: CanvasNode = {
...nodeData, ...nodeData,
id, id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
position: finalPosition,
}; };
set((state) => { set((state) => {
state.nodes.push(node); state.nodes.push(node);
if (node.type === 'image') {
state.lastAddedNodeId = id;
}
}); });
// 添加到历史 // 添加到历史
@ -474,6 +532,10 @@ export const useCanvasStore = create<CanvasStore>()(
clearPendingPickedObjects: () => set((state) => { clearPendingPickedObjects: () => set((state) => {
state.pendingPickedObjects = []; state.pendingPickedObjects = [];
}), }),
clearLastAddedNodeId: () => set((state) => {
state.lastAddedNodeId = null;
}),
})), })),
{ {
name: 'canvas-storage', name: 'canvas-storage',