feat: improve canvas workflow and auto title
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

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

View File

@ -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"]),

View File

@ -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);

View File

@ -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="新建画布"
>

View File

@ -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);
};
// 删除画布

View File

@ -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

View File

@ -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" />

View File

@ -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',