Merge remote-tracking branch 'origin/pr-573' into codex/pr-557-589-security-integration

This commit is contained in:
ddCat
2026-09-22 11:02:04 +08:00
6 changed files with 111 additions and 15 deletions

View File

@@ -7,6 +7,12 @@ export type CreationReference = CanvasResourceReference & {
attachmentId?: string;
};
export type CreationReferenceLimits = {
maxImages: number;
maxVideos: number;
maxAudios: number;
};
export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) {
const counts = { image: 0, video: 0, audio: 0, file: 0 };
const attachmentReferences = attachments.map((attachment) => {
@@ -34,6 +40,25 @@ export function reconcileCreationAttachmentLimit(attachments: CreationAttachment
return { attachments: nextAttachments, removedReferences };
}
export function reconcileCreationAttachmentLimits(attachments: CreationAttachment[], references: CreationReference[], limits: CreationReferenceLimits) {
const remaining = {
image: Math.max(0, Math.floor(limits.maxImages)),
video: Math.max(0, Math.floor(limits.maxVideos)),
audio: Math.max(0, Math.floor(limits.maxAudios)),
};
const nextAttachments = attachments.filter((attachment) => {
const kind = creationAttachmentKind(attachment);
if (kind === "file" || remaining[kind] <= 0) return false;
remaining[kind] -= 1;
return true;
});
if (nextAttachments.length === attachments.length) return { attachments, removedReferences: [] as CreationReference[] };
const retainedIds = new Set(nextAttachments.map((attachment) => attachment.id));
const removedReferences = references.filter((reference) => reference.attachmentId && !retainedIds.has(reference.attachmentId));
return { attachments: nextAttachments, removedReferences };
}
export function removeCreationReferenceTokens(value: string, references: CreationReference[]) {
return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join("").split(`@${reference.label}`).join(""), value);
}

View File

@@ -415,7 +415,7 @@ export function CreationComposer(props: ComposerProps) {
: "描述镜头内容、运动、光线与节奏";
const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作";
const imageReferencesSupported = props.imageProfile.references.maxImages > 0;
const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video");
const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.maxReferences > 0;
const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences;
const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`;
const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => {

View File

@@ -12,7 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions } from "@/lib/model-capabilities";
import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { inferVideoOperation, modelGroupReferenceLimits, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import type { BackendGenerationResult } from "@/services/api/generation-task";
import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
@@ -27,7 +27,7 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references";
import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference, type CreationReferenceLimits } from "./creation-references";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
import { defaultCreationMode, modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types";
import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations";
@@ -42,6 +42,18 @@ type CreationRuntime = Awaited<ReturnType<typeof loadCreationRuntime>>;
const TEXT_STREAMING_PREF_KEY = "creation.composer.text-streaming";
const TEXT_THINKING_PREF_KEY = "creation.composer.text-thinking";
function creationLibraryDisabledReason(mode: CreationMode, kind: string | undefined, videoLimits?: CreationReferenceLimits) {
if (!kind) return undefined;
if (mode === "image") return kind === "image" ? undefined : "图片创作仅支持参考图";
if (mode !== "video") return undefined;
const maximum = kind === "image" ? videoLimits?.maxImages : kind === "video" ? videoLimits?.maxVideos : kind === "audio" ? videoLimits?.maxAudios : 0;
if ((maximum || 0) > 0) return undefined;
const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "image" ? "图片" : "此类素材";
return `当前视频模型不支持参考${label}`;
}
function readComposerPref(key: string, fallback: boolean): boolean {
try {
const stored = window.localStorage.getItem(key);
@@ -152,7 +164,14 @@ export default function CreatePage() {
: config, [config, mode, ratio, seconds, selectedModel, videoQuality]);
const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]);
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
// 同名逻辑模型可能把文生视频、图生视频和全模态参考拆到不同路由。
// 入口需要展示整个模型组的引用能力,添加素材后再由兼容路由选择具体模型。
const videoReferenceLimits = useMemo(() => mode === "video"
? modelGroupReferenceLimits(config, preferredModel || selectedModel, "video") || { maxImages: 0, maxVideos: 0, maxAudios: 0 }
: undefined, [config, mode, preferredModel, selectedModel]);
const maxReferences = mode === "video"
? (videoReferenceLimits?.maxImages || 0) + (videoReferenceLimits?.maxVideos || 0) + (videoReferenceLimits?.maxAudios || 0)
: mode === "image" ? imageProfile.references.maxImages : 6;
const referenceImageSize = useMemo(() => {
const imageAttachments = attachments.filter(isImageAttachment);
if (imageAttachments.length !== 1) return undefined;
@@ -225,16 +244,16 @@ export default function CreatePage() {
setSeconds(normalized.seconds);
setRatio(normalized.ratio);
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0;
if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences));
}, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, videoProfile]);
useEffect(() => {
const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences);
const reconciled = mode === "video" && videoReferenceLimits
? reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits)
: reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences);
if (reconciled.attachments === attachments) return;
setAttachments(reconciled.attachments);
if (reconciled.removedReferences.length) setPrompt((current) => removeCreationReferenceTokens(current, reconciled.removedReferences));
}, [attachments, maxReferences, mentionReferences]);
}, [attachments, maxReferences, mentionReferences, mode, videoReferenceLimits]);
useEffect(() => {
let cancelled = false;
@@ -387,9 +406,9 @@ export default function CreatePage() {
const externalLibraryItems = useMemo<AssetLibraryPickerItem[]>(
() => externalAssetSources.items.map((item) => ({
...item,
disabledReason: mode === "image" && item.external?.item.kind !== "image" ? "图片创作仅支持参考图" : undefined,
disabledReason: creationLibraryDisabledReason(mode, item.external?.item.kind, videoReferenceLimits),
})),
[externalAssetSources.items, mode],
[externalAssetSources.items, mode, videoReferenceLimits],
);
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => [
...assets
@@ -401,10 +420,10 @@ export default function CreatePage() {
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
asset,
searchText: (asset.tags || []).join(" "),
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
disabledReason: creationLibraryDisabledReason(mode, asset.kind, videoReferenceLimits),
})),
...externalLibraryItems,
], [assets, externalLibraryItems, mode]);
], [assets, externalLibraryItems, mode, videoReferenceLimits]);
const uploadCreationAsset = async (file: File) => {
const { uploadImage, uploadMediaFile } = await loadCreationRuntime();
if (file.type.startsWith("video/")) {
@@ -455,7 +474,14 @@ export default function CreatePage() {
return external ? [creationAttachmentFromExternalAsset(external)] : [];
});
if (!next.length) return;
setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences));
setAttachments((current) => {
const candidates = [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next];
const reconciled = mode === "video" && videoReferenceLimits
? reconcileCreationAttachmentLimits(candidates, [], videoReferenceLimits)
: reconcileCreationAttachmentLimit(candidates, [], maxReferences);
if (reconciled.attachments.length < candidates.length) toast.warning("部分素材超出当前模型的参考内容上限,未添加到创作中");
return reconciled.attachments;
});
setLibraryOpen(false);
};
@@ -556,7 +582,10 @@ export default function CreatePage() {
releaseSubmitGate();
return;
}
if (attachments.length > maxReferences) {
const reconciledAttachments = mode === "video" && videoReferenceLimits
? reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits)
: reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences);
if (reconciledAttachments.attachments !== attachments) {
toast.warning("参考内容正在按当前模型能力调整,请稍后重试");
releaseRetryLock();
releaseSubmitGate();