diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx
index 095fe74f0..0c3c2f26c 100644
--- a/web/src/components/canvas/canvas-node-prompt-panel.tsx
+++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx
@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
-import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
+import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -43,6 +43,8 @@ type CanvasNodePromptPanelProps = {
mentionReferences?: CanvasResourceReference[];
onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void;
onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void;
+ onReplaceReference?: (nodeId: string, oldReference: CanvasResourceReference, sourceNodeId: string) => void;
+ onReplaceReferenceFiles?: (nodeId: string, oldReference: CanvasResourceReference, files: File[]) => void;
onClose?: () => void;
onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void;
onImageSettingsOpenChange?: (open: boolean) => void;
@@ -61,7 +63,7 @@ const PROMPT_EDITOR_VERTICAL_PADDING = 12;
const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20;
const PROMPT_EDITOR_MAX_LINES = 8;
-export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onReorderReferences, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
+export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const themeName = useThemeStore((state) => state.theme);
const theme = canvasThemes[themeName];
@@ -103,6 +105,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
size: node.metadata?.size || globalConfig.size,
quality: node.metadata?.quality || globalConfig.quality,
count: String(node.metadata?.count || globalConfig.count),
+ transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality,
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
@@ -438,11 +441,14 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
<>
onRemoveReference?.(node.id, reference)}
onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined}
+ onReplaceReference={onReplaceReference ? (oldReference, sourceNodeId) => onReplaceReference(node.id, oldReference, sourceNodeId) : undefined}
+ onReplaceReferenceFiles={onReplaceReferenceFiles ? (oldReference, files) => onReplaceReferenceFiles(node.id, oldReference, files) : undefined}
/>
onReplaceReferenceFiles(node.id, reference, files) : undefined}
onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}
containerClassName="min-h-0 flex-1"
className={expanded
@@ -627,10 +634,29 @@ function referenceShelfHeading(references: CanvasResourceReference[]) {
return `${label} · ${references.length}`;
}
-function ConnectedReferenceShelf({ references, theme, onInsert, onRemove, onReorder }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void; onRemove?: (reference: CanvasResourceReference) => void; onReorder?: (orderedNodeIds: string[]) => void }) {
+function ConnectedReferenceShelf({
+ targetNodeId,
+ references,
+ theme,
+ onInsert,
+ onRemove,
+ onReorder,
+ onReplaceReference,
+ onReplaceReferenceFiles,
+}: {
+ targetNodeId?: string;
+ references: CanvasResourceReference[];
+ theme: CanvasTheme;
+ onInsert: (reference: CanvasResourceReference) => void;
+ onRemove?: (reference: CanvasResourceReference) => void;
+ onReorder?: (orderedNodeIds: string[]) => void;
+ onReplaceReference?: (oldReference: CanvasResourceReference, sourceNodeId: string) => void;
+ onReplaceReferenceFiles?: (oldReference: CanvasResourceReference, files: File[]) => void;
+}) {
const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
const [imagePreview, setImagePreview] = useState(null);
const [draggedReferenceId, setDraggedReferenceId] = useState(null);
+ const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null);
if (!activeReferences.length) return null;
const moveReference = (sourceId: string, targetId: string) => {
@@ -657,21 +683,72 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove, onReor
{activeReferences.map((reference, index) => {
const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video");
+ const isDropTarget = dropTargetReferenceId === reference.id;
return (
{
- if (!onReorder || !draggedReferenceId) return;
- event.preventDefault();
- event.dataTransfer.dropEffect = "move";
+ if (draggedReferenceId) {
+ if (!onReorder) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ return;
+ }
+ const hasImageNode = event.dataTransfer.types.includes("application/x-canvas-image-node-id");
+ const hasFiles = event.dataTransfer.types.includes("Files");
+ if (hasImageNode || hasFiles) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "copy";
+ if (dropTargetReferenceId !== reference.id) {
+ setDropTargetReferenceId(reference.id);
+ }
+ }
+ }}
+ onDragLeave={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) {
+ if (dropTargetReferenceId === reference.id) {
+ setDropTargetReferenceId(null);
+ }
+ }
}}
onDrop={(event) => {
- event.preventDefault();
- const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain");
- setDraggedReferenceId(null);
- moveReference(sourceId, reference.nodeId);
+ if (dropTargetReferenceId === reference.id) {
+ setDropTargetReferenceId(null);
+ }
+ if (draggedReferenceId) {
+ event.preventDefault();
+ const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain");
+ setDraggedReferenceId(null);
+ moveReference(sourceId, reference.nodeId);
+ return;
+ }
+ const sourceNodeId = event.dataTransfer.getData("application/x-canvas-image-node-id");
+ if (sourceNodeId && sourceNodeId !== reference.nodeId && onReplaceReference) {
+ event.preventDefault();
+ event.stopPropagation();
+ onReplaceReference(reference, sourceNodeId);
+ return;
+ }
+ const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
+ if (files.length && onReplaceReferenceFiles) {
+ event.preventDefault();
+ event.stopPropagation();
+ onReplaceReferenceFiles(reference, files);
+ return;
+ }
}}
>
{onReorder ? (
@@ -875,6 +952,7 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
size: node.metadata?.size,
quality: node.metadata?.quality,
transparentBackground: node.metadata?.transparentBackground,
+ videoWatermark: node.metadata?.watermark,
count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
}
: {
@@ -898,18 +976,18 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
return {
...globalConfig,
model,
- quality: defaults.quality || globalConfig.quality || defaultConfig.quality,
+ quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality,
size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
- transparentBackground: defaults.transparentBackground || "false",
+ transparentBackground: defaults.transparentBackground ?? "false",
videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
- videoGenerateAudio: defaults.videoGenerateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
- videoWatermark: defaults.videoWatermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
+ videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
+ videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
- count: defaults.count || String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
+ count: defaults.count ?? String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
};
}
diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts
index 672e0bb45..372b3392f 100644
--- a/web/src/lib/canvas/canvas-resource-references.ts
+++ b/web/src/lib/canvas/canvas-resource-references.ts
@@ -215,7 +215,7 @@ function canvasReferenceIdentityChanged(previousReferences: CanvasResourceRefere
return previousReferences.some((reference) => nextLabelByNodeId.get(reference.nodeId) !== reference.label);
}
-function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string) {
+export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string) {
const hasExistingContent = (node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim())) || (node.type === CanvasNodeType.Image && Boolean(node.metadata?.content));
const promptTemplateMetadata = node.metadata?.promptTemplateOperation
? { promptTemplateOperation: undefined, promptTemplateVariables: undefined }
@@ -232,7 +232,7 @@ function removeCanvasMentionToken(value: string, token: string) {
return replaceCanvasMentionToken(value, token, "");
}
-function replaceCanvasMentionToken(value: string, token: string, replacement: string) {
+export function replaceCanvasMentionToken(value: string, token: string, replacement: string) {
if (!token) return value;
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Numbered media mentions can touch Chinese prose or another mention, but not a longer number.
@@ -242,6 +242,34 @@ function replaceCanvasMentionToken(value: string, token: string, replacement: st
return value.replace(new RegExp(`${escapedToken}${boundary}`, "gu"), replacement);
}
+/**
+ * 替换提示词中针对某参考对象的所有引用(包含 @图片1、@原标题、@[node:xxx])。
+ * 只要提示词中存在指向该对象的标记,同步全部替换为新标记。
+ */
+export function replaceCanvasReferenceMentions(
+ prompt: string,
+ oldReference: { label?: string; title?: string; nodeId?: string },
+ replacementToken: string,
+ replacementTitle?: string,
+): string {
+ let result = prompt;
+ const cleanToken = replacementToken.startsWith("@") ? replacementToken : `@${replacementToken}`;
+ const cleanOldLabel = oldReference.label?.replace(/^@/, "");
+ if (cleanOldLabel) {
+ result = replaceCanvasMentionToken(result, `@${cleanOldLabel}`, cleanToken);
+ }
+ const cleanOldTitle = oldReference.title?.replace(/^@/, "");
+ if (cleanOldTitle && cleanOldTitle !== cleanOldLabel) {
+ const cleanRepTitle = replacementTitle?.replace(/^@/, "");
+ const targetTitleToken = cleanRepTitle ? `@${cleanRepTitle}` : cleanToken;
+ result = replaceCanvasMentionToken(result, `@${cleanOldTitle}`, targetTitleToken);
+ }
+ if (oldReference.nodeId) {
+ result = replaceCanvasMentionToken(result, canvasNodeMentionToken(oldReference.nodeId), cleanToken);
+ }
+ return result;
+}
+
function compactRemovedCanvasMentionPrompt(value: string) {
return value
.replace(/[ \t]+\n/g, "\n")
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index d6ef927bf..792a933f5 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -28,6 +28,7 @@ import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { App, Button } from "antd";
+import { ArrowLeftRight } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import { getNodeSpec } from "@/constant/canvas";
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
@@ -74,7 +75,7 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
import { useFocusMode } from "@/hooks/use-focus-mode";
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
-import { applyCanvasConnectionPromptSync, getContextResourceNodes, normalizeCanvasNodeMentionTokens, reorderCanvasResourceConnections, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
+import { applyCanvasConnectionPromptSync, buildCanvasNodeMentionReferenceMap, getContextResourceNodes, normalizeCanvasNodeMentionTokens, reorderCanvasResourceConnections, replaceCanvasReferenceMentions, writeCanvasNodePrompt, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
@@ -726,6 +727,7 @@ function InfiniteCanvasPage() {
openAssetsAtPosition,
pasteAssistantImage,
pasteSystemClipboard,
+ replaceNodeMedia,
startUploadStatus,
uploadModalOpen,
uploadTimelineMedia,
@@ -955,12 +957,84 @@ function InfiniteCanvasPage() {
onNodesDeleted: handleNodesDeleted,
});
+ const handleReplaceNodeReference = useCallback((
+ targetNodeId: string,
+ oldReference: { id: string; nodeId?: string; label?: string; title?: string },
+ sourceNodeId: string,
+ ) => {
+ const sourceNode = nodesRef.current.find((n) => n.id === sourceNodeId);
+ if (!sourceNode || sourceNode.id === oldReference.nodeId) return;
+
+ const previousNodes = nodesRef.current;
+ const previousConnections = connectionsRef.current;
+
+ const configNodeId = previousConnections.find((c) => c.fromNodeId === targetNodeId && previousNodes.find((n) => n.id === c.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId;
+ const receiverId = configNodeId || targetNodeId;
+
+ let rewired = false;
+ const nextConnections = previousConnections.map((c) => {
+ if (!rewired && c.fromNodeId === oldReference.nodeId && (c.toNodeId === targetNodeId || c.toNodeId === configNodeId)) {
+ rewired = true;
+ return { ...c, fromNodeId: sourceNodeId };
+ }
+ return c;
+ });
+
+ if (!rewired) {
+ nextConnections.push({
+ id: nanoid(),
+ fromNodeId: sourceNodeId,
+ toNodeId: receiverId,
+ });
+ }
+
+ const nextReferencesMap = buildCanvasNodeMentionReferenceMap(previousNodes, nextConnections, previousNodes);
+ const targetNextReferences = nextReferencesMap.get(targetNodeId) || [];
+ const newRef = targetNextReferences.find((r) => r.nodeId === sourceNodeId);
+
+ const targetNode = previousNodes.find((n) => n.id === targetNodeId);
+ let nextNodes = previousNodes;
+ if (targetNode && newRef) {
+ const currentPrompt = targetNode.metadata?.composerContent ?? targetNode.metadata?.prompt ?? "";
+ const replacementToken = `@${newRef.label}`;
+ const updatedPrompt = replaceCanvasReferenceMentions(
+ currentPrompt,
+ oldReference,
+ replacementToken,
+ sourceNode.title ? sourceNode.title : newRef.label,
+ );
+
+ nextNodes = previousNodes.map((n) => (n.id === targetNodeId ? writeCanvasNodePrompt(n, updatedPrompt) : n));
+ }
+
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ message.success(`已将参考图「${oldReference.label || "参考图"}」替换为「${sourceNode.title || "新图片"}」,提示词已同步更新`);
+ }, [connectionsRef, message, nodesRef, setConnections, setNodes]);
+
+ const handleReplaceNodeReferenceFiles = useCallback((
+ _targetNodeId: string,
+ oldReference: { id: string; nodeId?: string; label?: string; title?: string },
+ files: File[],
+ ) => {
+ const file = files.find((f) => f.type.startsWith("image/"));
+ if (!file || !oldReference.nodeId) return;
+ void replaceNodeMedia(oldReference.nodeId, file).then((success: boolean) => {
+ if (success) {
+ message.success("参考图片已替换");
+ }
+ });
+ }, [message, replaceNodeMedia]);
+
const {
cancelPendingConnectionCreate,
closeConnectionCreateMenu,
connectionTargetAnchorRatio,
connectionTargetNodeId,
connectionApproach,
+ connectionReplaceHover,
connectingParams,
createConnectedNode,
getConnectionCreateDisabledReason,
@@ -988,6 +1062,7 @@ function InfiniteCanvasPage() {
setContextMenu,
setDialogNodeId,
setDrawingNodeId,
+ onReplaceReference: handleReplaceNodeReference,
});
const batchSourceNodeIds = useMemo(() => nodes
@@ -1953,6 +2028,8 @@ function InfiniteCanvasPage() {
onGenerate={handleGenerateNode}
onRemoveReference={handleRemoveNodeReference}
onReorderReferences={handleReorderNodeReferences}
+ onReplaceReference={handleReplaceNodeReference}
+ onReplaceReferenceFiles={handleReplaceNodeReferenceFiles}
onClose={() => setDialogNodeId(null)}
onNodeMouseDown={handleNodeMouseDown}
workspaceMode={workspaceMode}
@@ -1963,7 +2040,7 @@ function InfiniteCanvasPage() {
/>
);
},
- [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, handleReorderNodeReferences, mentionReferencesByNodeId, projectId, runningNodeId, skillMentionReferences, workspaceMode],
+ [configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, handleReorderNodeReferences, handleReplaceNodeReference, handleReplaceNodeReferenceFiles, mentionReferencesByNodeId, projectId, runningNodeId, skillMentionReferences, workspaceMode],
);
const renderCanvasNodeContent = useCallback(
@@ -2400,9 +2477,9 @@ function InfiniteCanvasPage() {
onReloadResource={reloadCanvasNodeResource}
onOpenTaskDetails={openCanvasNodeTaskDetails}
onOpenVersions={openCanvasNodeVersions}
- onViewImage={viewCanvasNodeImage}
- onReplaceMedia={replaceCanvasNodeMedia}
- onOpenTextEditor={openTextNodeEditor}
+ onViewImage={viewCanvasNodeImage}
+ onReplaceMedia={replaceCanvasNodeMedia}
+ onOpenTextEditor={openTextNodeEditor}
onOpenDirector={editCanvasDirector}
onOpenDrawing={openDrawingNode}
onStartBatchConnection={startBatchConnection}
@@ -2591,6 +2668,23 @@ function InfiniteCanvasPage() {
/>
) : null}
+ {connectionReplaceHover ? (
+
+
+
松开替换
+
+ @{connectionReplaceHover.referenceLabel}
+
+
+ ) : null}
+
{selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
>;
setDialogNodeId: Dispatch>;
setDrawingNodeId: Dispatch>;
+ onReplaceReference?: (targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => void;
+};
+
+export type ConnectionReplaceHover = {
+ targetNodeId: string;
+ referenceLabel: string;
+ clientX: number;
+ clientY: number;
};
type ConnectionDropTarget = {
@@ -81,6 +89,7 @@ export function useCanvasConnectionController({
setContextMenu,
setDialogNodeId,
setDrawingNodeId,
+ onReplaceReference,
}: UseCanvasConnectionControllerOptions) {
const { message } = App.useApp();
const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
@@ -89,6 +98,7 @@ export function useCanvasConnectionController({
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null);
const [connectionApproach, setConnectionApproach] = useState(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState();
+ const [connectionReplaceHover, setConnectionReplaceHover] = useState(null);
const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null);
const [batchConnectionPreview, setBatchConnectionPreview] = useState(null);
const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 });
@@ -101,6 +111,28 @@ export function useCanvasConnectionController({
const batchConnectionPointerStartRef = useRef(null);
const pointerMoveFrameRef = useRef(null);
const latestPointerMoveRef = useRef(null);
+ const hoveredReplaceElRef = useRef(null);
+
+ const updateConnectionReplaceHover = useCallback((element: HTMLElement | null, clientX = 0, clientY = 0) => {
+ if (hoveredReplaceElRef.current === element) {
+ if (element) {
+ setConnectionReplaceHover((prev) => prev ? { ...prev, clientX, clientY } : null);
+ }
+ return;
+ }
+ if (hoveredReplaceElRef.current) {
+ hoveredReplaceElRef.current.removeAttribute("data-connection-hover-replace");
+ }
+ hoveredReplaceElRef.current = element;
+ if (element) {
+ element.setAttribute("data-connection-hover-replace", "true");
+ const targetNodeId = element.getAttribute("data-target-node-id") || "";
+ const referenceLabel = element.getAttribute("data-reference-label") || "参考图";
+ setConnectionReplaceHover({ targetNodeId, referenceLabel, clientX, clientY });
+ } else {
+ setConnectionReplaceHover(null);
+ }
+ }, []);
useLayoutEffect(() => {
connectingParamsRef.current = connectingParams;
@@ -123,13 +155,14 @@ export function useCanvasConnectionController({
connectingParamsRef.current = next;
setConnectingParams(next);
if (!next) {
+ updateConnectionReplaceHover(null);
setConnectionApproach(null);
connectingPointerIdRef.current = null;
connectingPointerStartRef.current = null;
setConnectionTargetNodeId(null);
setConnectionTargetAnchorRatio(undefined);
}
- }, []);
+ }, [updateConnectionReplaceHover]);
const closeConnectionCreateMenu = useCallback(() => {
pendingConnectionCreateRef.current = null;
@@ -545,9 +578,69 @@ export function useCanvasConnectionController({
}, [finishBatchConnection, message]);
const finishConnection = useCallback((clientX: number, clientY: number) => {
+ updateConnectionReplaceHover(null);
if (pendingConnectionCreateRef.current) return;
const currentConnection = connectingParamsRef.current;
if (!currentConnection) return;
+
+ // 1. 如果是从输出端(source)拖出的连线,检查是否拖到了参考图卡片或参考区上进行替换
+ if (currentConnection.handleType === "source" && typeof document !== "undefined") {
+ const hitElement = document.elementFromPoint(clientX, clientY);
+ if (hitElement) {
+ // A) 直接释放在提示词面板的参考图卡片上
+ const refChip = hitElement.closest("[data-reference-chip]");
+ if (refChip) {
+ const targetNodeId = refChip.getAttribute("data-target-node-id");
+ const oldNodeId = refChip.getAttribute("data-reference-node-id");
+ const oldLabel = refChip.getAttribute("data-reference-label") || "";
+ const oldRefId = refChip.getAttribute("data-reference-id") || "";
+ const oldTitle = refChip.getAttribute("data-reference-title") || oldLabel;
+ if (targetNodeId && oldNodeId && targetNodeId !== currentConnection.nodeId && onReplaceReference) {
+ if (oldNodeId === currentConnection.nodeId) {
+ setConnecting(null);
+ return;
+ }
+ onReplaceReference(targetNodeId, { id: oldRefId, nodeId: oldNodeId, label: oldLabel, title: oldTitle }, currentConnection.nodeId);
+ setConnecting(null);
+ return;
+ }
+ }
+
+ // B) 释放在提示词参考架容器内(自动就近匹配最近的参考卡片)
+ const refShelf = hitElement.closest(".canvas-node-composer-references");
+ if (refShelf) {
+ const chips = Array.from(refShelf.querySelectorAll("[data-reference-chip]"));
+ let closestChip: HTMLElement | null = null;
+ let minDistance = Number.POSITIVE_INFINITY;
+ chips.forEach((chip) => {
+ const rect = chip.getBoundingClientRect();
+ const centerX = rect.left + rect.width / 2;
+ const dist = Math.abs(clientX - centerX);
+ if (dist < minDistance) {
+ minDistance = dist;
+ closestChip = chip;
+ }
+ });
+ if (closestChip) {
+ const targetNodeId = (closestChip as HTMLElement).getAttribute("data-target-node-id");
+ const oldNodeId = (closestChip as HTMLElement).getAttribute("data-reference-node-id");
+ const oldLabel = (closestChip as HTMLElement).getAttribute("data-reference-label") || "";
+ const oldRefId = (closestChip as HTMLElement).getAttribute("data-reference-id") || "";
+ const oldTitle = (closestChip as HTMLElement).getAttribute("data-reference-title") || oldLabel;
+ if (targetNodeId && oldNodeId && targetNodeId !== currentConnection.nodeId && onReplaceReference) {
+ if (oldNodeId === currentConnection.nodeId) {
+ setConnecting(null);
+ return;
+ }
+ onReplaceReference(targetNodeId, { id: oldRefId, nodeId: oldNodeId, label: oldLabel, title: oldTitle }, currentConnection.nodeId);
+ setConnecting(null);
+ return;
+ }
+ }
+ }
+ }
+ }
+
const dropTarget = getConnectionDropTarget(clientX, clientY, currentConnection);
if (dropTarget.nodeId) {
connectNodes(currentConnection, dropTarget.nodeId, dropTarget.handleId, dropTarget.anchorRatio);
@@ -561,7 +654,7 @@ export function useCanvasConnectionController({
pendingConnectionCreateRef.current = pending;
setPendingConnectionCreate(pending);
}
- }, [connectNodes, getConnectionDropTarget, screenToCanvas, setConnecting]);
+ }, [connectNodes, getConnectionDropTarget, onReplaceReference, screenToCanvas, setConnecting, updateConnectionReplaceHover]);
const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => {
event.preventDefault();
@@ -606,6 +699,30 @@ export function useCanvasConnectionController({
}
const current = connectingParamsRef.current;
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
+ if (current.handleType === "source" && typeof document !== "undefined") {
+ const el = document.elementFromPoint(event.clientX, event.clientY);
+ let chip = el?.closest("[data-reference-chip]");
+ if (!chip) {
+ const shelf = el?.closest(".canvas-node-composer-references");
+ if (shelf) {
+ const chips = Array.from(shelf.querySelectorAll("[data-reference-chip]"));
+ let closestChip: HTMLElement | null = null;
+ let minDistance = Number.POSITIVE_INFINITY;
+ chips.forEach((c) => {
+ const rect = c.getBoundingClientRect();
+ const dist = Math.abs(event.clientX - (rect.left + rect.width / 2));
+ if (dist < minDistance) {
+ minDistance = dist;
+ closestChip = c;
+ }
+ });
+ chip = closestChip;
+ }
+ }
+ updateConnectionReplaceHover(chip || null, event.clientX, event.clientY);
+ } else {
+ updateConnectionReplaceHover(null);
+ }
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
const point = screenToCanvas(event.clientX, event.clientY);
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point));
@@ -662,6 +779,7 @@ export function useCanvasConnectionController({
};
const handlePointerCancel = (event: PointerEvent) => {
cancelPendingPointerMove();
+ updateConnectionReplaceHover(null);
if (batchConnectionPointerIdRef.current === event.pointerId) {
clearBatchConnection();
return;
@@ -670,6 +788,7 @@ export function useCanvasConnectionController({
};
const cancel = () => {
cancelPendingPointerMove();
+ updateConnectionReplaceHover(null);
if (connectingParamsRef.current) setConnecting(null);
if (batchConnectionPreviewRef.current) clearBatchConnection();
};
@@ -692,6 +811,7 @@ export function useCanvasConnectionController({
connectionTargetNodeId,
connectionApproach,
connectionTargetAnchorRatio,
+ connectionReplaceHover,
connectingParams,
createConnectedNode,
getConnectionCreateDisabledReason,
diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts
index 780a07c84..b98db81fe 100644
--- a/web/src/pages/canvas/use-canvas-upload.ts
+++ b/web/src/pages/canvas/use-canvas-upload.ts
@@ -720,6 +720,7 @@ export function useCanvasUpload({
openAssetsAtPosition,
pasteAssistantImage,
pasteSystemClipboard,
+ replaceNodeMedia,
startUploadStatus,
uploadModalOpen,
uploadStatus,
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index fdd4e5e39..75102f8fd 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -9425,6 +9425,15 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent);
}
+ .canvas-node-reference-chip[data-connection-hover-replace="true"] {
+ position: relative;
+ outline: 2px solid #3b82f6 !important;
+ outline-offset: 1px !important;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25), 0 4px 16px rgba(0, 0, 0, 0.4) !important;
+ transition: box-shadow 0.15s ease, outline-color 0.15s ease !important;
+ z-index: 50 !important;
+ }
+
.canvas-node-reference-chip[data-dragging="true"] {
opacity: 0.58;
}
diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts
index 514b73699..97f99bf76 100644
--- a/web/test/canvas-resource-references.test.ts
+++ b/web/test/canvas-resource-references.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
-import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections, reorderCanvasResourceConnections } from "../src/lib/canvas/canvas-resource-references";
+import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections, reorderCanvasResourceConnections, replaceCanvasMentionToken, replaceCanvasReferenceMentions } from "../src/lib/canvas/canvas-resource-references";
import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset";
import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
@@ -383,3 +383,47 @@ describe("reorder canvas resource connections", () => {
expect(nextConnections[2]).toBe(unrelated);
});
});
+
+describe("replace canvas reference mentions", () => {
+ test("替换参考图时将提示词内所有提及同张图片的标记全部替换", () => {
+ const oldRef: CanvasResourceReference = {
+ id: "node-old",
+ nodeId: "node-old",
+ kind: "image",
+ label: "图片1",
+ title: "卡通可爱动画风画册",
+ active: true,
+ };
+ const prompt = "@图片1 参考 @图片2 生成,保持 @图片1 的色调与 @卡通可爱动画风画册 的画风";
+ const replaced = replaceCanvasReferenceMentions(prompt, oldRef, "@图片3", "未来赛博超跑");
+ expect(replaced).toBe("@图片3 参考 @图片2 生成,保持 @图片3 的色调与 @未来赛博超跑 的画风");
+ });
+
+ test("不会误替换较长数字或无关标记", () => {
+ const oldRef: CanvasResourceReference = {
+ id: "node-old",
+ nodeId: "node-old",
+ kind: "image",
+ label: "图片1",
+ title: "画册",
+ active: true,
+ };
+ const prompt = "@图片1 和 @图片10 及 @图片11,不要改动 @图片10";
+ const replaced = replaceCanvasReferenceMentions(prompt, oldRef, "@图片9");
+ expect(replaced).toBe("@图片9 和 @图片10 及 @图片11,不要改动 @图片10");
+ });
+
+ test("自动规范化带有或缺失@前缀的参数", () => {
+ const oldRef: CanvasResourceReference = {
+ id: "node-old",
+ nodeId: "node-old",
+ kind: "image",
+ label: "@图片1",
+ title: "@画册",
+ active: true,
+ };
+ const prompt = "@图片1 的画风结合 @画册 的色调";
+ const replaced = replaceCanvasReferenceMentions(prompt, oldRef, "图片2", "新封面");
+ expect(replaced).toBe("@图片2 的画风结合 @新封面 的色调");
+ });
+});