From 11d9d469f5c2c1808bf9a03f9f398a2df7adba72 Mon Sep 17 00:00:00 2001 From: echoD886 Date: Sat, 15 Aug 2026 21:23:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=88=9B=E4=BD=9C=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=20-=20=E5=8F=82=E8=80=83=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=A7=BD=E4=BD=8D=E9=81=B5=E5=BE=AA=E6=A8=A1=E5=9E=8B=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E5=B9=B6=E4=BF=9D=E7=95=99=E7=8E=B0=E6=9C=89=E8=A7=86?= =?UTF-8?q?=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留当前项目样式,仅吸收参考内容主槽位展示、模型上限收敛和对应行为测试。 Refs #232 --- web/package.json | 2 +- web/src/pages/create/creation-references.ts | 14 +++++ web/src/pages/create/index.tsx | 60 +++++++++++++++------ web/src/styles/globals.css | 9 ++++ web/test/create-library-button.test.ts | 9 ++++ web/test/creation-references.test.ts | 39 ++++++++++++++ 6 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 web/test/creation-references.test.ts diff --git a/web/package.json b/web/package.json index d0cf7ce4c..68de65b85 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index e9954b356..6b6e73860 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -20,6 +20,20 @@ export function selectedCreationReferences(prompt: string, references: CreationR return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference))); } +export function reconcileCreationAttachmentLimit(attachments: CreationAttachment[], references: CreationReference[], maxReferences: number) { + const limit = Math.max(0, Math.floor(maxReferences)); + if (attachments.length <= limit) return { attachments, removedReferences: [] as CreationReference[] }; + + const nextAttachments = attachments.slice(0, limit); + const removedAttachmentIds = new Set(attachments.slice(limit).map((attachment) => attachment.id)); + const removedReferences = references.filter((reference) => reference.attachmentId && removedAttachmentIds.has(reference.attachmentId)); + return { attachments: nextAttachments, removedReferences }; +} + +export function removeCreationReferenceTokens(value: string, references: CreationReference[]) { + return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); +} + export function displayCreationPrompt(prompt: string, references: CreationReference[]) { return references.reduce((value, reference) => value.split(canvasResourceMentionToken(reference)).join(`@${reference.label}`), prompt); } diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index d0d53f374..2f8924d61 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -8,7 +8,6 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelPicker } from "@/components/model-picker"; -import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references"; import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; @@ -30,7 +29,7 @@ import { beginGenerationConsumer, runGenerationConsumer } from "@/services/gener import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; -import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references"; +import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; @@ -171,6 +170,7 @@ export default function CreatePage() { const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; 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 mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]); @@ -184,7 +184,6 @@ export default function CreatePage() { setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); - if (attachments.length > imageProfile.references.maxImages) setAttachments((current) => current.slice(0, imageProfile.references.maxImages)); }, [mode, selectedModel, imageProfile]); useEffect(() => { @@ -195,6 +194,13 @@ export default function CreatePage() { setVideoQuality(normalized.resolution.replace(/p$/i, "")); }, [mode, selectedModel, videoProfile]); + useEffect(() => { + const reconciled = 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]); + useEffect(() => { let cancelled = false; void loadCreationConversations().then((stored) => { @@ -304,7 +310,6 @@ export default function CreatePage() { } }; - const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; const libraryItems = useMemo(() => assets .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video") .map((asset) => ({ @@ -384,7 +389,7 @@ export default function CreatePage() { const removeAttachment = (id: string) => { const reference = mentionReferences.find((item) => item.attachmentId === id); setAttachments((current) => current.filter((item) => item.id !== id)); - if (reference) setPrompt((current) => removeReferenceTokens(current, [reference])); + if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference])); }; const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => { @@ -406,6 +411,11 @@ export default function CreatePage() { releaseRetryLock(); return; } + if (attachments.length > maxReferences) { + toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); + releaseRetryLock(); + return; + } const settings = { ratio, seconds, quality, videoQuality, count }; const references = selectedCreationReferences(text, mentionReferences); // 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。 @@ -668,6 +678,7 @@ export default function CreatePage() { setPrompt, busy, attachments, + maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, onOpenLibrary: () => setLibraryOpen(true), @@ -820,6 +831,26 @@ function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: return {url ? type === "video" ? ; } +function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false, onPreview, onRemove, onAdd }: { + item: CreationAttachment; + primary?: boolean; + canAddMore?: boolean; + onPreview: (type: "image" | "video", url: string) => void; + onRemove: (id: string) => void; + onAdd?: () => void; +}) { + const isVideo = isVideoAttachment(item); + const url = isVideo ? item.url : item.previewUrl; + return
+ + + {primary && canAddMore && onAdd ? : null} +
; +} + type ComposerProps = { variant: "empty" | "thread"; mode: CreationMode; @@ -827,6 +858,7 @@ type ComposerProps = { setPrompt: (value: string) => void; busy: boolean; attachments: CreationAttachment[]; + maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onOpenLibrary: () => void; @@ -867,18 +899,20 @@ 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 [primaryAttachment, ...secondaryAttachments] = props.attachments; + const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; + const previewAttachment = (type: "image" | "video", url: string) => { + setPreviewType(type); + setPreviewUrl(url); + }; return
- + {primaryAttachment ? : }
- {props.attachments.length ?
{props.attachments.map((item) => { - const isVideo = isVideoAttachment(item); - const url = isVideo ? item.url : item.previewUrl; - return
; - })}
: null} + {secondaryAttachments.length ?
{secondaryAttachments.map((item) => )}
: null}