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 = "【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。";
|
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);
|
||||||
|
|||||||
@ -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"]),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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="新建画布"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除画布
|
// 删除画布
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user