fix(merge): 本地整合 - 保留主分支交互与样式并排除不安全用户删除

This commit is contained in:
ddCat committed 2026-09-07 23:02:34 +08:00
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 [];
+4 -2
View File
@@ -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 };
}
+95
View File
@@ -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" };
}
+2
View File
@@ -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;
+3 -1
View File
@@ -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 } };
}
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。