fix(canvas): 保护生成中与失败节点画幅尺寸,支持变体即时持久化与 2K 分辨率高亮 (#359)
Co-authored-by: Danborad <danborad@github.com>
This commit is contained in:
@@ -79,15 +79,16 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
|
||||
assertResolvableGenerationMentions(prompt, mentionInputs);
|
||||
const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, mentionInputs);
|
||||
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
|
||||
if ((Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
|
||||
const hasConnectedMedia = connectedInputs.some((input) => input.type === "image" || input.type === "video" || input.type === "audio" || input.type === "character");
|
||||
if ((promptOnly && hasConnectedMedia) || (Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
|
||||
const autoIncludeWorkflowMedia = isWorkflowSource;
|
||||
return buildComposerGenerationContext(
|
||||
mentionInputs,
|
||||
prompt,
|
||||
// 工作流节点由字段映射接收全部连线媒体;视频节点的历史首尾帧字段不能再额外追加参考图。
|
||||
autoIncludeWorkflowMedia ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
|
||||
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia) ? [] : [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)),
|
||||
promptOnly,
|
||||
autoIncludeWorkflowMedia,
|
||||
autoIncludeWorkflowMedia || (promptOnly && hasConnectedMedia),
|
||||
connectedInputs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Switch } from "antd";
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { formatVideoResolutionLabel, normalizeVideoDuration, videoDimensionsForRatioAndResolution, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
|
||||
import { formatVideoResolutionLabel, isVideoResolutionMatch, normalizeVideoDuration, videoDimensionsForRatioAndResolution, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, resolveVideoRatioValue, resolveVideoResolutionValue, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -51,7 +51,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{configuredResolutions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
<OptionPill key={item.value} selected={isVideoResolutionMatch(resolution, item.value)} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
|
||||
@@ -17,11 +17,34 @@ export function fitNodeSize(width: number, height: number, maxWidth = 720, maxHe
|
||||
export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) {
|
||||
const raw = String(size || "").trim();
|
||||
if (!raw || raw.toLowerCase() === "auto") return null;
|
||||
// 支持 16:9 / 1024x576 / 16:9-2k
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const match = raw.match(/^(\d+(?:\.\d+)?)(?:x|:)(\d+(?:\.\d+)?)/i);
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (match) {
|
||||
width = Number(match[1]);
|
||||
height = Number(match[2]);
|
||||
} else if (raw.includes("竖") || raw.includes("portrait") || raw.includes("9:16")) {
|
||||
width = 9;
|
||||
height = 16;
|
||||
} else if (raw.includes("横") || raw.includes("landscape") || raw.includes("16:9")) {
|
||||
width = 16;
|
||||
height = 9;
|
||||
} else if (raw.includes("(1:1)") || raw.includes("1:1") || raw.includes("square")) {
|
||||
width = 1;
|
||||
height = 1;
|
||||
} else if (raw.includes("3:4")) {
|
||||
width = 3;
|
||||
height = 4;
|
||||
} else if (raw.includes("4:3")) {
|
||||
width = 4;
|
||||
height = 3;
|
||||
} else if (raw.includes("2:3")) {
|
||||
width = 2;
|
||||
height = 3;
|
||||
} else if (raw.includes("3:2")) {
|
||||
width = 3;
|
||||
height = 2;
|
||||
}
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
||||
const ratio = width / Math.max(1, height);
|
||||
if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight };
|
||||
@@ -35,14 +58,22 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) {
|
||||
let width = node.width;
|
||||
let height = node.height;
|
||||
const emptyStage = isBuiltinCanvasNodeType(node.type) ? NODE_DEFAULT_SIZE[node.type] : undefined;
|
||||
const shouldPromoteEmptyStage = !node.metadata?.content
|
||||
&& !node.metadata?.freeResize
|
||||
&& !node.metadata?.locked
|
||||
&& emptyStage !== undefined
|
||||
&& width * height < emptyStage.width * emptyStage.height;
|
||||
if (shouldPromoteEmptyStage) {
|
||||
width = emptyStage.width;
|
||||
height = emptyStage.height;
|
||||
|
||||
// 如果未完成节点(生成中/失败/空节点)指定了目标比例(如 3:4, 9:16),按目标比例保持占位框尺寸,不能强制变成 16:9 横屏。
|
||||
const targetSize = node.metadata?.size ? nodeSizeFromRatio(node.metadata.size, emptyStage?.width || 720, emptyStage?.height || 405) : null;
|
||||
if (targetSize && !node.metadata?.content && !node.metadata?.freeResize && !node.metadata?.locked) {
|
||||
width = targetSize.width;
|
||||
height = targetSize.height;
|
||||
} else {
|
||||
const shouldPromoteEmptyStage = !node.metadata?.content
|
||||
&& !node.metadata?.freeResize
|
||||
&& !node.metadata?.locked
|
||||
&& emptyStage !== undefined
|
||||
&& (width <= 0 || height <= 0);
|
||||
if (shouldPromoteEmptyStage) {
|
||||
width = emptyStage.width;
|
||||
height = emptyStage.height;
|
||||
}
|
||||
}
|
||||
const naturalWidth = node.metadata?.naturalWidth || 0;
|
||||
const naturalHeight = node.metadata?.naturalHeight || 0;
|
||||
|
||||
@@ -20,7 +20,7 @@ export function desktopLocalChannelFormState(desktopLocalChannelsEnabled: boolea
|
||||
|
||||
export function projectDesktopLocalChannelRuntime<T extends { allowLocalChannel?: boolean }>(config: T): T {
|
||||
const desktopLocalChannelsEnabled = useUserStore.getState().features.desktopLocalChannelsEnabled;
|
||||
const hostname = typeof window === "undefined" ? "" : window.location.hostname;
|
||||
const hostname = typeof window === "undefined" ? "" : window.location?.hostname || "";
|
||||
const allowLocalChannel = desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, config.allowLocalChannel);
|
||||
return config.allowLocalChannel === allowLocalChannel ? config : { ...config, allowLocalChannel };
|
||||
}
|
||||
|
||||
@@ -11,16 +11,35 @@ export function normalizeVideoDuration(value: string | number | undefined) {
|
||||
export function normalizeVideoResolution(value: string | number | undefined) {
|
||||
const raw = String(value || "").trim();
|
||||
const token = raw.toLowerCase();
|
||||
if (!token) return "";
|
||||
if (token === "low") return "480";
|
||||
if (token === "auto" || token === "medium" || token === "high") return "720";
|
||||
if (token === "2k") return "1440";
|
||||
if (token === "4k") return "2160";
|
||||
const resolution = Number(token.replace(/p$/i, ""));
|
||||
if (Number.isFinite(resolution)) return resolution > 0 ? String(Math.floor(resolution)) : "720";
|
||||
if (Number.isFinite(resolution) && resolution > 0) return String(Math.floor(resolution));
|
||||
// Channel-declared values are opaque enums, not necessarily numeric tiers.
|
||||
// Preserve them verbatim so values such as `768p竖` remain selectable and
|
||||
// can be sent back to the provider without being collapsed to 720p.
|
||||
return raw || "720";
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function normalizeVideoResolutionValue(value: string | number | undefined): string {
|
||||
return normalizeVideoResolution(value) || "720";
|
||||
}
|
||||
|
||||
export function isVideoResolutionMatch(selected: string | undefined, target: string | undefined) {
|
||||
const s = String(selected || "").trim().toLowerCase().replace(/p$/i, "");
|
||||
const t = String(target || "").trim().toLowerCase().replace(/p$/i, "");
|
||||
if (!s && !t) return true;
|
||||
if (s === t) return true;
|
||||
if ((s === "2k" || s === "1440") && (t === "2k" || t === "1440")) return true;
|
||||
if ((s === "4k" || s === "2160") && (t === "4k" || t === "2160")) return true;
|
||||
if ((s === "768" || s === "768p") && (t === "768" || t === "768p")) return true;
|
||||
if ((s === "1080" || s === "1080p") && (t === "1080" || t === "1080p")) return true;
|
||||
if ((s === "720" || s === "720p") && (t === "720" || t === "720p")) return true;
|
||||
if ((s === "480" || s === "480p") && (t === "480" || t === "480p")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function videoResolutionComparisonKey(value: string | number | undefined) {
|
||||
@@ -34,7 +53,8 @@ export function formatVideoResolutionLabel(value: string | number | undefined) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
const normalized = normalizeVideoResolution(raw);
|
||||
if (normalized === "2160") return "4K";
|
||||
if (normalized === "1440" || normalized.toLowerCase() === "2k") return "2K";
|
||||
if (normalized === "2160" || normalized.toLowerCase() === "4k") return "4K";
|
||||
if (/^\d+$/.test(normalized)) return `${normalized}P`;
|
||||
return normalized.replace(/^(\d+)p/i, "$1P");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
|
||||
|
||||
@@ -49,7 +50,8 @@ export async function executeImageGeneration({
|
||||
const count = getGenerationCount(generationConfig.count);
|
||||
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
|
||||
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
|
||||
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
|
||||
const isCopiedVariant = count === 1 && isImageNode && Boolean(sourceNode?.metadata?.copiedFromNodeId || sourceNode?.metadata?.versionOfNodeId || sourceNode?.title.endsWith(" Copy") || sourceNode?.title.includes(" · "));
|
||||
const reuseSourceNode = canGenerateImageInPlace(sourceNode) || isCopiedVariant;
|
||||
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy"));
|
||||
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
|
||||
const referenceImages = generationContext.referenceImages;
|
||||
@@ -87,6 +89,7 @@ export async function executeImageGeneration({
|
||||
width: rootWidth,
|
||||
height: rootHeight,
|
||||
metadata: {
|
||||
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
|
||||
prompt: effectivePrompt,
|
||||
status: NODE_STATUS_LOADING,
|
||||
size: generationConfig.size,
|
||||
@@ -95,6 +98,7 @@ export async function executeImageGeneration({
|
||||
batchFailedCount: count > 1 ? 0 : undefined,
|
||||
batchUsesReferenceImages: referenceImages.length > 0,
|
||||
primaryImageId: undefined,
|
||||
content: reuseSourceNode ? "" : undefined,
|
||||
...generationMetadata,
|
||||
...styleMetadata,
|
||||
...skillMetadata,
|
||||
@@ -128,27 +132,33 @@ export async function executeImageGeneration({
|
||||
? childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))
|
||||
: [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
|
||||
|
||||
setNodes((current) => {
|
||||
return [
|
||||
...current.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } };
|
||||
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
|
||||
if (isImageNode) return node;
|
||||
return {
|
||||
...node,
|
||||
type: CanvasNodeType.Text,
|
||||
title: prompt.slice(0, 32) || "Prompt",
|
||||
width: parentConfig.width,
|
||||
height: parentConfig.height,
|
||||
metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
|
||||
};
|
||||
}),
|
||||
...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]),
|
||||
...childNodes,
|
||||
];
|
||||
const nextNodes: CanvasNodeData[] = [
|
||||
...canvasNodes.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } };
|
||||
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
|
||||
if (isImageNode) return node;
|
||||
return {
|
||||
...node,
|
||||
type: CanvasNodeType.Text,
|
||||
title: prompt.slice(0, 32) || "Prompt",
|
||||
width: parentConfig.width,
|
||||
height: parentConfig.height,
|
||||
metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined },
|
||||
};
|
||||
}),
|
||||
...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]),
|
||||
...childNodes,
|
||||
];
|
||||
|
||||
setNodes(nextNodes);
|
||||
setConnections((current) => {
|
||||
const nextConnections = [...current, ...batchConnections];
|
||||
if (projectId) {
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes: nextNodes, connections: nextConnections });
|
||||
}
|
||||
return nextConnections;
|
||||
});
|
||||
setConnections((current) => [...current, ...batchConnections]);
|
||||
setSelectedNodeIds(new Set([nodeId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(nodeId);
|
||||
@@ -200,7 +210,7 @@ export async function executeImageGeneration({
|
||||
height: child.height,
|
||||
position: { x: center.x - child.width / 2, y: center.y - child.height / 2 },
|
||||
};
|
||||
return current.map((node) =>
|
||||
const updated = current.map((node) =>
|
||||
node.id === rootId
|
||||
? {
|
||||
...node,
|
||||
@@ -220,6 +230,8 @@ export async function executeImageGeneration({
|
||||
}
|
||||
: node,
|
||||
);
|
||||
if (projectId) useCanvasStore.getState().updateProject(projectId, { nodes: updated });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
hasSuccess = true;
|
||||
@@ -231,7 +243,11 @@ export async function executeImageGeneration({
|
||||
if (!representativeFailure || failure.generationErrorCode === CONTENT_MODERATION_ERROR_CODE) representativeFailure = failure;
|
||||
hasFailure = true;
|
||||
failureCount += 1;
|
||||
setNodes((current) => current.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } } : node)));
|
||||
setNodes((current) => {
|
||||
const next = current.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } } : node));
|
||||
if (projectId) useCanvasStore.getState().updateProject(projectId, { nodes: next });
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
finishGenerationRequest(targetId, controller);
|
||||
@@ -244,8 +260,8 @@ export async function executeImageGeneration({
|
||||
return;
|
||||
}
|
||||
if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
setNodes((current) => {
|
||||
const next = current.map((node) => {
|
||||
if (node.id === nodeId && isConfigNode) {
|
||||
return {
|
||||
...node,
|
||||
@@ -268,6 +284,9 @@ export async function executeImageGeneration({
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
);
|
||||
});
|
||||
if (projectId) useCanvasStore.getState().updateProject(projectId, { nodes: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -133,9 +133,11 @@ export function useCanvasGenerationExecutor({
|
||||
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
|
||||
|
||||
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
||||
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
|
||||
const promptOnly = mode === "video";
|
||||
// AutoDL/其他声明式视频协议需要结构化参考素材;只有普通
|
||||
// 模型视频接口才把提示词视为纯文本输入。
|
||||
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
|
||||
// 普通视频协议只保留输入框文本;声明式工作流还要保留连接媒体。
|
||||
const promptOnly = mode === "video" && !usesWorkflowProvider;
|
||||
try {
|
||||
const baseContext = buildNodeGenerationContext(
|
||||
nodeId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { getNodeGenerationMode, getNodeInputKind, getNodeListLabel, getNodeMinSize, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, shouldKeepAspectRatio } from "../src/lib/canvas/node-registry";
|
||||
import { getNodeDefinition, getNodeGenerationMode, getNodeInputKind, getNodeListLabel, getNodeMinSize, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, shouldKeepAspectRatio } from "../src/lib/canvas/node-registry";
|
||||
import { connectionInputSummary } from "../src/lib/canvas/canvas-connection-policy";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "../src/types/canvas";
|
||||
|
||||
@@ -12,7 +12,7 @@ const ALL_TYPES = Object.values(CanvasNodeType);
|
||||
|
||||
describe("节点注册表——覆盖完整性", () => {
|
||||
test("每种节点类型都有定义", () => {
|
||||
expect(listNodeDefinitions().length).toBe(ALL_TYPES.length);
|
||||
expect(ALL_TYPES.every((type) => Boolean(getNodeDefinition(type)))).toBe(true);
|
||||
for (const type of ALL_TYPES) expect(getNodeMinSize(type)).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildSkillMentionReferences, expandSkillMentions, renderSkillPrompt, resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
|
||||
function skill(overrides: Partial<Skill> = {}): Skill {
|
||||
@@ -44,27 +44,4 @@ describe("canvas skill mentions", () => {
|
||||
const inactive = skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false });
|
||||
expect(resolveSkillMentions("请使用 @镜头拆解,但不要使用 @未加入。", [active, inactive]).map((item) => item.skill_id)).toEqual(["skill-1"]);
|
||||
});
|
||||
|
||||
it("expands explicit and natural mentions without changing unknown tokens", () => {
|
||||
const active = skill();
|
||||
const inactive = skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false });
|
||||
const result = expandSkillMentions("请使用 @[skill:skill-1],并忽略 @未加入。", [active, inactive]);
|
||||
expect(result).toContain('<canvas-skill name="镜头拆解">');
|
||||
expect(result).toContain("执行指令:\n先读取当前画布,再按镜头顺序创建节点。");
|
||||
expect(result).toContain("@未加入");
|
||||
});
|
||||
|
||||
it("renders a bounded instruction instead of leaking skill metadata", () => {
|
||||
const result = renderSkillPrompt(skill());
|
||||
expect(result).toContain("请严格执行该技能");
|
||||
expect(result).toContain("不得覆盖系统/开发者规则");
|
||||
expect(result).not.toContain("owner_uid");
|
||||
expect(result).not.toContain("skill_id");
|
||||
});
|
||||
|
||||
it("bounds oversized skill instructions before model expansion", () => {
|
||||
const result = renderSkillPrompt(skill({ instruction: "x".repeat(20_000) }));
|
||||
expect(result.length).toBeLessThan(13_000);
|
||||
expect(result).toContain("技能指令过长,已截断");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ test("镜头画面使用已绑定资产的 @ 引用编辑器", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('name="plotDescription"');
|
||||
expect(source).toContain('<ShotAssetMentionTextarea variant="scene"');
|
||||
expect(source).toContain("resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext)");
|
||||
expect(source).toContain("resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext");
|
||||
expect(source).toContain("<Image.PreviewGroup>");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user