fix(media,canvas): 对象存储参考素材优先 URL,并同步清理图片引用提示词 (#434)

* fix(media): 对象存储参考素材 - 协议支持 URL 时优先发送签名链接

开启对象存储后,支持 URL 的上游协议优先发送 OSS/COS/七牛/S3 签名链接;必须传字节的协议仍走 Base64 或 multipart。本地资源回退字节,签发失败直接报错,不自动重发生成。

* fix(canvas): 图片引用清理 - 取消引用时同步移除提示词引用
This commit is contained in:
Rou
2026-09-07 22:28:19 +08:00
committed by GitHub
parent 469627c792
commit 21b83eca2e
9 changed files with 339 additions and 41 deletions

View File

@@ -51,6 +51,100 @@ export function normalizeCanvasNodeMentionTokens(prompt: string, references: Can
}, prompt);
}
const CANVAS_RESOURCE_MENTION_BOUNDARY = /(?:$|\s|[,.!?;:,。!?;:、)\]}】)])/;
function canvasResourceReferenceMentionTokens(reference: CanvasResourceReference) {
const tokens = [canvasResourceMentionToken(reference), `@${reference.label}`];
if (reference.nodeId && !reference.assetId && reference.kind !== "skill") tokens.push(canvasNodeMentionToken(reference.nodeId));
return [...new Set(tokens.filter(Boolean))];
}
function removeCanvasResourceReferenceTokens(prompt: string, references: CanvasResourceReference[]) {
return compactRemovedCanvasMentionPrompt(references.reduce((value, reference) => {
let next = value;
for (const token of canvasResourceReferenceMentionTokens(reference)) {
next = removeCanvasMentionToken(next, token);
}
return next;
}, prompt));
}
function rewriteCanvasPromptAfterReferenceChange(prompt: string, previousReferences: CanvasResourceReference[], nextReferences: CanvasResourceReference[]) {
const previousCanvasReferences = canvasNodeBoundReferences(previousReferences);
const nextCanvasReferences = canvasNodeBoundReferences(nextReferences);
if (!previousCanvasReferences.length || !canvasReferenceIdentityChanged(previousCanvasReferences, nextCanvasReferences)) return prompt;
const nextByNodeId = new Map(nextCanvasReferences.map((reference) => [reference.nodeId, reference]));
let value = prompt;
previousCanvasReferences.forEach((reference) => {
const nodeToken = canvasNodeMentionToken(reference.nodeId);
canvasResourceReferenceMentionTokens(reference).forEach((token) => {
if (token !== nodeToken) value = replaceCanvasMentionToken(value, token, nodeToken);
});
});
previousCanvasReferences.filter((reference) => !nextByNodeId.has(reference.nodeId)).forEach((reference) => {
value = removeCanvasMentionToken(value, canvasNodeMentionToken(reference.nodeId));
});
value = normalizeCanvasNodeMentionTokens(value, nextCanvasReferences);
return value === prompt ? prompt : compactRemovedCanvasMentionPrompt(value);
}
export function applyCanvasConnectionPromptSync(previousNodes: CanvasNodeData[], previousConnections: CanvasConnection[], nextNodes: CanvasNodeData[], nextConnections: CanvasConnection[]) {
const previousMap = buildCanvasNodeMentionReferenceMap(previousNodes, previousConnections, nextNodes);
const nextMap = buildCanvasNodeMentionReferenceMap(nextNodes, nextConnections, nextNodes);
let changed = false;
const mapped = nextNodes.map((node) => {
const previousPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
const nextPrompt = rewriteCanvasPromptAfterReferenceChange(previousPrompt, previousMap.get(node.id) || [], nextMap.get(node.id) || []);
if (nextPrompt === previousPrompt) return node;
changed = true;
return writeCanvasNodePrompt(node, nextPrompt);
});
return changed ? mapped : nextNodes;
}
function canvasNodeBoundReferences(references: CanvasResourceReference[]) {
return references.filter((reference) => reference.nodeId && !reference.assetId && reference.kind !== "skill");
}
function canvasReferenceIdentityChanged(previousReferences: CanvasResourceReference[], nextReferences: CanvasResourceReference[]) {
if (previousReferences.length !== nextReferences.length) return true;
const nextLabelByNodeId = new Map(nextReferences.map((reference) => [reference.nodeId, reference.label]));
return previousReferences.some((reference) => nextLabelByNodeId.get(reference.nodeId) !== reference.label);
}
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 }
: {};
return {
...node,
metadata: hasExistingContent
? { ...node.metadata, ...promptTemplateMetadata, composerContent: prompt }
: { ...node.metadata, ...promptTemplateMetadata, prompt, composerContent: prompt },
};
}
function removeCanvasMentionToken(value: string, token: string) {
return replaceCanvasMentionToken(value, token, "");
}
function replaceCanvasMentionToken(value: string, token: string, replacement: string) {
if (!token) return value;
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return value.replace(new RegExp(`${escapedToken}(?=${CANVAS_RESOURCE_MENTION_BOUNDARY.source})`, "gu"), replacement);
}
function compactRemovedCanvasMentionPrompt(value: string) {
return value
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.replace(/^[ \t]+|[ \t]+$/g, "")
.replace(/^\n+|\n+$/g, "");
}
export function buildAssetMentionReferences(assets: Asset[]): CanvasResourceReference[] {
return assets.flatMap((asset): CanvasResourceReference[] => {
if (asset.kind === "model") return [];

View File

@@ -70,7 +70,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 { getContextResourceNodes, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { applyCanvasConnectionPromptSync, getContextResourceNodes, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
@@ -1173,20 +1173,28 @@ function InfiniteCanvasPage() {
const referenceNodeId = reference.nodeId;
if (!referenceNodeId) return;
// 生成节点可能通过配置节点接收参考,只移除参考来源边,保留目标到配置节点的主链。
const configNodeId = connectionsRef.current.find((connection) => {
const previousNodes = nodesRef.current;
const previousConnections = connectionsRef.current;
const configNodeId = previousConnections.find((connection) => {
if (connection.fromNodeId !== targetNodeId) return false;
return nodesRef.current.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config;
return previousNodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config;
})?.toNodeId;
const removedConnectionIds = new Set(
connectionsRef.current
previousConnections
.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId))
.map((connection) => connection.id),
);
if (!removedConnectionIds.size) return;
connectionsRef.current = connectionsRef.current.filter((connection) => !removedConnectionIds.has(connection.id));
setConnections(connectionsRef.current);
const nextConnections = previousConnections.filter((connection) => !removedConnectionIds.has(connection.id));
const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
if (nextNodes !== previousNodes) {
nodesRef.current = nextNodes;
setNodes(nextNodes);
}
connectionsRef.current = nextConnections;
setConnections(nextConnections);
setSelectedConnectionId((current) => current && removedConnectionIds.has(current) ? null : current);
}, [connectionsRef, nodesRef, setConnections, setSelectedConnectionId]);
}, [connectionsRef, nodesRef, setConnections, setNodes, setSelectedConnectionId]);
const handleProjectFolderInsert = useCallback((folderId: string) => {
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);

View File

@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { applyCanvasConnectionPromptSync } from "@/lib/canvas/canvas-resource-references";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
@@ -325,20 +326,28 @@ export function useCanvasNodeOperations({
const deleteNodes = useCallback((ids: Set<string>) => {
if (!ids.size) return;
const result = removeCanvasNodes(nodesRef.current, ids);
const removedNodes = nodesRef.current.filter((node) => result.removedIds.has(node.id));
const nextConnections = connectionsRef.current.filter((connection) => !result.removedIds.has(connection.fromNodeId) && !result.removedIds.has(connection.toNodeId));
commitNodes(result.nodes);
const previousNodes = nodesRef.current;
const previousConnections = connectionsRef.current;
const result = removeCanvasNodes(previousNodes, ids);
const removedNodes = previousNodes.filter((node) => result.removedIds.has(node.id));
const nextConnections = previousConnections.filter((connection) => !result.removedIds.has(connection.fromNodeId) && !result.removedIds.has(connection.toNodeId));
const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, result.nodes, nextConnections);
commitNodes(nextNodes);
commitConnections(nextConnections);
selectNodes(new Set());
onNodesDeleted(result.removedIds, result.nodes, removedNodes);
onNodesDeleted(result.removedIds, nextNodes, removedNodes);
}, [commitConnections, commitNodes, connectionsRef, nodesRef, onNodesDeleted, selectNodes]);
const deleteConnection = useCallback((connectionId: string) => {
commitConnections(connectionsRef.current.filter((connection) => connection.id !== connectionId));
const previousNodes = nodesRef.current;
const previousConnections = connectionsRef.current;
const nextConnections = previousConnections.filter((item) => item.id !== connectionId);
const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
if (nextNodes !== previousNodes) commitNodes(nextNodes);
commitConnections(nextConnections);
setSelectedConnectionId((current) => current === connectionId ? null : current);
setContextMenu((current) => current?.type === "connection" && current.connectionId === connectionId ? null : current);
}, [commitConnections, connectionsRef, setContextMenu, setSelectedConnectionId]);
}, [commitConnections, commitNodes, connectionsRef, nodesRef, setContextMenu, setSelectedConnectionId]);
const duplicateNode = useCallback((nodeId: string, duplicateMode: "variant" | "copy" = "variant") => {
const source = nodesRef.current.find((node) => node.id === nodeId);

View File

@@ -93,4 +93,16 @@ describe("canvas node generation position mentions", () => {
expect(context.prompt).toBe("让 @图片1 进入画面");
expect(context.prompt).not.toContain("@[node:");
});
test("取消引用后的提示词不再因为悬挂 @图片1 阻断生成", () => {
const target = targetNode();
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
const { applyCanvasConnectionPromptSync } = require("../src/lib/canvas/canvas-resource-references") as typeof import("../src/lib/canvas/canvas-resource-references");
const [nextTarget] = applyCanvasConnectionPromptSync([image, target], [connection(image.id)], [image, target], []).filter((item: typeof target) => item.id === target.id);
const context = buildNodeGenerationContext(nextTarget.id, [image, nextTarget], [], nextTarget.metadata?.composerContent || "", []);
expect(nextTarget.metadata?.composerContent).toBe("让 进入画面");
expect(context.referenceImages).toEqual([]);
expect(context.prompt).toBe("让 进入画面");
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
import { applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } 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";
@@ -205,3 +205,45 @@ describe("canvas resource mention slots", () => {
})).toBe("@[asset:asset-a]");
});
});
describe("remove canvas resource mention tokens", () => {
test("取消引用后会清掉对应的 @图片N", () => {
const image = imageNode("image-a");
const target = {
...videoNode("target"),
metadata: { composerContent: "@图片1 生成角色设定图:保持同一角色身份。" },
};
const previousConnections = [connection(image.id, target.id)];
const [nextTarget] = applyCanvasConnectionPromptSync([image, target], previousConnections, [image, target], []).filter((node) => node.id === target.id);
expect(nextTarget.metadata?.composerContent).toBe("生成角色设定图:保持同一角色身份。");
expect(nextTarget.metadata?.composerContent).not.toContain("@图片1");
});
test("同时存在节点 token 时一并清掉", () => {
const image = imageNode("image-a");
const target = {
...videoNode("target"),
metadata: { composerContent: "让 @图片1 和 @[node:image-a] 一起进入画面" },
};
const [nextTarget] = applyCanvasConnectionPromptSync([image, target], [connection(image.id, target.id)], [image, target], []).filter((node) => node.id === target.id);
expect(nextTarget.metadata?.composerContent).toBe("让 和 一起进入画面");
expect(nextTarget.metadata?.composerContent).not.toContain("@图片1");
expect(nextTarget.metadata?.composerContent).not.toContain("@[node:image-a]");
});
test("多图时只清被移除的那张,并把剩余引用重新编号", () => {
const imageA = imageNode("image-a");
const imageB = imageNode("image-b");
const target = {
...videoNode("target"),
metadata: { composerContent: "比较 @图片1 和 @图片2" },
};
const previousConnections = [connection(imageA.id, target.id), connection(imageB.id, target.id)];
const nextConnections = [connection(imageB.id, target.id)];
const [nextTarget] = applyCanvasConnectionPromptSync([imageA, imageB, target], previousConnections, [imageA, imageB, target], nextConnections).filter((node) => node.id === target.id);
expect(nextTarget.metadata?.composerContent).toBe("比较 和 @图片1");
});
});