fix(merge): 本地整合 - 保留主分支交互与样式并排除不安全用户删除
This commit is contained in:
commit
14adaa1e99
100 files changed
+4845
-865
No files matched your search
@@ -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 [];
|
||||
|
||||
@@ -59,11 +59,13 @@ function parseImageResolutionOption(value: string): ImageResolutionOption | null
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) return null;
|
||||
const pixels = width * height;
|
||||
const tier: ImageResolutionTier | null = pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : null;
|
||||
if (!tier) return null;
|
||||
const ratio = closestRatio(width / height);
|
||||
if (!ratio) return null;
|
||||
let a = width, b = height;
|
||||
while (b) [a, b] = [b, a % b];
|
||||
const ratio = closestRatio(width / height) || `${width / a}:${height / a}`;
|
||||
return { size: `${width}x${height}`, tier, ratio, width, height };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { buildImageResolutionOptions, type ImageResolutionOption, type ImageResolutionTier } from "./image-resolution-tiers";
|
||||
import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
|
||||
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
|
||||
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"];
|
||||
const standardSizes: Record<string, string[]> = {
|
||||
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
|
||||
"16:9": ["1824x1024", "2752x1536", "3840x2160"],
|
||||
"9:16": ["1024x1824", "1536x2752", "2160x3840"],
|
||||
"4:3": ["1360x1024", "2304x1728", "3264x2448"],
|
||||
"3:4": ["1024x1360", "1728x2304", "2448x3264"],
|
||||
"3:2": ["1536x1024", "2496x1664", "3504x2336"],
|
||||
"2:3": ["1024x1536", "1664x2496", "2336x3504"],
|
||||
"21:9": ["2048x878", "3136x1344", "3808x1632"],
|
||||
};
|
||||
|
||||
export function imagePresetForRatio(tier: ImageResolutionTier, input: string): ImageResolutionOption {
|
||||
const match = input.trim().match(/^(\d{1,5})\s*[::]\s*(\d{1,5})$/);
|
||||
if (!match) throw new Error("请输入宽高比,例如 16:9");
|
||||
let w = Number(match[1]),
|
||||
h = Number(match[2]);
|
||||
if (!w || !h || Math.max(w, h) / Math.min(w, h) > 3) throw new Error("宽高比必须为正数,且不超过 3:1");
|
||||
let a = w,
|
||||
b = h;
|
||||
while (b) [a, b] = [b, a % b];
|
||||
w /= a;
|
||||
h /= a;
|
||||
const ratio =
|
||||
Object.keys(standardSizes).find((value) => {
|
||||
const [width, height] = value.split(":").map(Number);
|
||||
return width * h === height * w;
|
||||
}) || `${w}:${h}`;
|
||||
const index = IMAGE_RESOLUTIONS.indexOf(tier);
|
||||
if (index < 0) throw new Error("分辨率仅支持 1K、2K、4K");
|
||||
const standard = standardSizes[ratio]?.[index];
|
||||
const pixels = [1_048_576, 4_194_304, 8_294_400][index];
|
||||
let width = Math.round(Math.sqrt((pixels * w) / h) / 16) * 16;
|
||||
let height = Math.round((width * h) / w / 16) * 16;
|
||||
while (width * height > pixels || Math.max(width, height) > 3840) {
|
||||
if (width >= height) {
|
||||
width -= 16;
|
||||
height = Math.round((width * h) / w / 16) * 16;
|
||||
} else {
|
||||
height -= 16;
|
||||
width = Math.round((height * w) / h / 16) * 16;
|
||||
}
|
||||
}
|
||||
if (standard) [width, height] = standard.split("x").map(Number);
|
||||
return { tier, ratio, width, height, size: `${width}x${height}` };
|
||||
}
|
||||
|
||||
export function imageQualityForTier(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
const aliases = { "1k": ["1k", "low"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
|
||||
return profile.quality.supported ? profile.quality.values.find((value) => aliases.includes(value.toLowerCase())) : undefined;
|
||||
}
|
||||
|
||||
export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) {
|
||||
return profile.size.parameter === "aspect_ratio" && IMAGE_RESOLUTIONS.some((tier) => imageQualityForTier(profile, tier));
|
||||
}
|
||||
|
||||
export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
// 比例协议必须能把档位传给上游;允许自定义比例不等于支持任意分辨率。
|
||||
if (profile.size.parameter !== "aspect_ratio") return profile.size.parameter !== "none";
|
||||
return imageResolutionUsesQuality(profile) ? Boolean(imageQualityForTier(profile, tier)) : tier === "1k";
|
||||
}
|
||||
|
||||
export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutionOption[] {
|
||||
if (profile.size.parameter === "none") return [];
|
||||
if (profile.size.presets) return profile.size.presets;
|
||||
const pixels = buildImageResolutionOptions(profile.size.values);
|
||||
const tiers = profile.size.parameter === "aspect_ratio" ? IMAGE_RESOLUTIONS.filter((tier) => imageQualityForTier(profile, tier)) : ["1k" as const];
|
||||
const ratios = (tiers.length ? tiers : ["1k" as const]).flatMap((tier) =>
|
||||
profile.size.values.flatMap((ratio) => {
|
||||
try {
|
||||
const preset = imagePresetForRatio(tier, ratio);
|
||||
return pixels.some((pixel) => pixel.ratio === preset.ratio) ? [] : [preset];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return [...pixels, ...ratios];
|
||||
}
|
||||
|
||||
export function imagePresetValue(profile: ImageCapabilityConfig, preset: ImageResolutionOption) {
|
||||
if (!profile.size.presets && !profile.size.allowCustom && preset.tier === "1k" && profile.size.values.includes(preset.ratio) && !profile.size.values.includes(preset.size)) return preset.ratio;
|
||||
return profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size;
|
||||
}
|
||||
|
||||
export function imageSizeConfigWithPresets(profile: ImageCapabilityConfig, presets: ImageResolutionOption[]): ImageCapabilityConfig["size"] {
|
||||
const values = [...new Set(presets.map((preset) => (profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size)))];
|
||||
if (profile.size.values.includes("auto")) values.unshift("auto");
|
||||
if (!values.length) values.push("auto");
|
||||
return { ...profile.size, presets, values, default: values.includes(profile.size.default) ? profile.size.default : values[0] || "auto" };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
|
||||
import type { ImageResolutionOption } from "@/lib/image-resolution-tiers";
|
||||
|
||||
export type ModelCapabilityConfig = {
|
||||
version: number;
|
||||
@@ -31,6 +32,7 @@ export type ImageCapabilityConfig = {
|
||||
values: string[];
|
||||
default: string;
|
||||
allowCustom: boolean;
|
||||
presets?: ImageResolutionOption[];
|
||||
};
|
||||
quality: {
|
||||
supported: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelInputSummary = {
|
||||
@@ -229,7 +230,8 @@ export function mergedImageCapabilityConfig(config: AiConfig, selected: string):
|
||||
const allowCustom = profiles.some((profile) => profile.size.allowCustom || profile.size.values.includes("*"));
|
||||
const values = concreteValues.length ? concreteValues : allowCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
|
||||
const base = selectedProfile || profiles[0];
|
||||
return { ...base, size: { ...base.size, values, allowCustom } };
|
||||
const presets = profiles.some((profile) => profile.size.presets) ? [...new Map(profiles.flatMap((profile) => imageSizePresets(profile)).map((preset) => [`${preset.tier}:${preset.ratio}:${preset.size}`, preset])).values()] : undefined;
|
||||
return { ...base, size: { ...base.size, values, allowCustom, presets } };
|
||||
}
|
||||
|
||||
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
|
||||
|
||||
Reference in new issue
Block a user